From a04450ab19a135f97040dade50cd4421932912a0 Mon Sep 17 00:00:00 2001 From: David Newell Date: Wed, 24 Apr 2024 16:18:27 +0100 Subject: [PATCH 01/16] feat: add new css parser --- packages/rrweb-snapshot/package.json | 8 +++-- packages/rrweb-snapshot/src/rebuild.ts | 8 +++-- .../__snapshots__/integration.test.ts.snap | 6 ++++ yarn.lock | 35 ++++++++++++++++++- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 14eb0fe0d9..a0d1e9bb74 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "2.0.0-alpha.13", + "version": "2.0.0-alpha.12", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "scripts": { "prepare": "npm run prepack", @@ -58,6 +58,10 @@ "rollup-plugin-typescript2": "^0.31.2", "ts-jest": "^27.0.5", "ts-node": "^7.0.1", - "tslib": "^1.9.3" + "tslib": "^1.9.3", + "@types/css-tree": "^2.3.7" + }, + "dependencies": { + "css-tree": "^2.3.1" } } diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 7c6ed948e6..d7a146cb8c 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -1,4 +1,4 @@ -import { Rule, Media, NodeWithRules, parse } from './css'; +import { Rule, Media, NodeWithRules, parse as parseOld } from './css'; import { serializedNodeWithId, NodeType, @@ -8,6 +8,7 @@ import { legacyAttributes, } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; +import { parse, findAll } from 'css-tree'; const tagMap: tagMap = { script: 'noscript', @@ -70,7 +71,7 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; - const ast = parse(cssText, { + const ast = parseOld(cssText, { silent: true, }); @@ -78,6 +79,9 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { return cssText; } + const newAst = parse(cssText); + const newSelectors: string[] = findAll(newAst, (node, _, _) => node.type === 'Selector'); + const selectors: string[] = []; const medias: string[] = []; function getSelectors(rule: Rule | Media | NodeWithRules) { diff --git a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap index f537fc7b29..39c8c49ee1 100644 --- a/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap +++ b/packages/rrweb-snapshot/test/__snapshots__/integration.test.ts.snap @@ -364,6 +364,12 @@ exports[`integration tests [html file]: picture-in-frame.html 1`] = ` " `; +exports[`integration tests [html file]: picture-with-inline-onload.html 1`] = ` +" + \\"This + " +`; + exports[`integration tests [html file]: preload.html 1`] = ` " diff --git a/yarn.lock b/yarn.lock index 40f8fd25f3..4dead9fad0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3277,6 +3277,11 @@ resolved "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz" integrity sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q== +"@types/css-tree@^2.3.7": + version "2.3.7" + resolved "https://registry.yarnpkg.com/@types/css-tree/-/css-tree-2.3.7.tgz#02818035f0d9cf88c2e6983c838526e5624bf189" + integrity sha512-LUlutQBpR2TgqZJdvXCPOx9EME7a4PHSEo2Y2c8POFpj1E9a6V94PUZNwjVdfHWyb8RQZoNHTYOKs980+sOi+g== + "@types/cssom@^0.4.1": version "0.4.1" resolved "https://registry.npmjs.org/@types/cssom/-/cssom-0.4.1.tgz#fb64e145b425bd6c1b0ed78ebd66ba43b6e088ab" @@ -5474,6 +5479,14 @@ css-tree@^1.0.0-alpha.39, css-tree@^1.1.2: mdn-data "2.0.14" source-map "^0.6.1" +css-tree@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" + integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== + dependencies: + mdn-data "2.0.30" + source-map-js "^1.0.1" + css-what@^3.2.1: version "3.4.2" resolved "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz" @@ -5564,10 +5577,15 @@ csso@^4.0.2: dependencies: css-tree "^1.1.2" -cssom@^0.4.4, cssom@^0.5.0, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": +cssom@^0.4.4, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": version "0.6.0" resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + cssom@~0.3.6: version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" @@ -10802,6 +10820,11 @@ mdn-data@2.0.14: resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz" integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== +mdn-data@2.0.30: + version "2.0.30" + resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" + integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== + mdn-data@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz" @@ -13160,6 +13183,11 @@ rollup@~2.78.0: optionalDependencies: fsevents "~2.3.2" +rrweb-snapshot@^2.0.0-alpha.13: + version "2.0.0-alpha.13" + resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.13.tgz#25097ae71ca4ac6c66edf3cae754607886f689e2" + integrity sha512-slbhNBCYjxLGCeH95a67ECCy5a22nloXp1F5wF7DCzUNw80FN7tF9Lef1sRGLNo32g3mNqTc2sWLATlKejMxYw== + run-async@^2.4.0: version "2.4.1" resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz" @@ -13562,6 +13590,11 @@ sorcery@^0.11.0: minimist "^1.2.0" sander "^0.5.0" +source-map-js@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" From f3fa19314456a5435bd9295993bfb0760329a0d3 Mon Sep 17 00:00:00 2001 From: David Newell Date: Wed, 24 Apr 2024 16:48:30 +0100 Subject: [PATCH 02/16] make selectors change --- packages/rrweb-snapshot/src/rebuild.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index d7a146cb8c..364e5d28ad 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -8,7 +8,7 @@ import { legacyAttributes, } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; -import { parse, findAll } from 'css-tree'; +import { parse, findAll, generate } from 'css-tree'; const tagMap: tagMap = { script: 'noscript', @@ -71,6 +71,9 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; + const newAst = parse(cssText); + const newSelectors = findAll(newAst, (node) => node.type === 'Selector').map(selector => generate(selector)); + const ast = parseOld(cssText, { silent: true, }); @@ -79,9 +82,6 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { return cssText; } - const newAst = parse(cssText); - const newSelectors: string[] = findAll(newAst, (node, _, _) => node.type === 'Selector'); - const selectors: string[] = []; const medias: string[] = []; function getSelectors(rule: Rule | Media | NodeWithRules) { @@ -102,10 +102,10 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { getSelectors(ast.stylesheet); let result = cssText; - if (selectors.length > 0) { + if (newSelectors.length > 0) { const selectorMatcher = new RegExp( - selectors - .filter((selector, index) => selectors.indexOf(selector) === index) + newSelectors + .filter((selector, index) => newSelectors.indexOf(selector) === index) .sort((a, b) => b.length - a.length) .map((selector) => { return escapeRegExp(selector); From 28b19d768ec783b39f63198672ee17848c384091 Mon Sep 17 00:00:00 2001 From: David Newell Date: Wed, 24 Apr 2024 18:23:35 +0100 Subject: [PATCH 03/16] selectors and tests --- packages/rrweb-snapshot/src/rebuild.ts | 19 ++++++++++--- packages/rrweb-snapshot/test/rebuild.test.ts | 28 +++++++------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 364e5d28ad..ea218a753c 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -8,7 +8,15 @@ import { legacyAttributes, } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; -import { parse, findAll, generate } from 'css-tree'; +import type {ParseOptions, CssNode, FindFn, GenerateOptions} from 'css-tree'; +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires +const css: CSSTree = require('css-tree'); + +interface CSSTree { + parse: (text: string, options?: ParseOptions) => CssNode + findAll: (ast: CssNode, fn: FindFn) => CssNode[]; + generate: (ast: CssNode, options?: GenerateOptions) => string +} const tagMap: tagMap = { script: 'noscript', @@ -71,8 +79,11 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; - const newAst = parse(cssText); - const newSelectors = findAll(newAst, (node) => node.type === 'Selector').map(selector => generate(selector)); + const newAst = css.parse(cssText); + const newSelectors = css + .findAll(newAst, (node) => node.type === 'Selector') + .map(node => css.generate(node)) + .filter(selector => HOVER_SELECTOR.test(selector)); const ast = parseOld(cssText, { silent: true, @@ -101,7 +112,7 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { } getSelectors(ast.stylesheet); - let result = cssText; + let result = css.generate(newAst); if (newSelectors.length > 0) { const selectorMatcher = new RegExp( newSelectors diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index 097ff0989a..384bc627c1 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -84,21 +84,21 @@ describe('rebuild', function () { describe('add hover class to hover selector related rules', function () { it('will do nothing to css text without :hover', () => { - const cssText = 'body { color: white }'; + const cssText = 'body{color:white}'; expect(adaptCssForReplay(cssText, cache)).toEqual(cssText); }); it('can add hover class to css text', () => { const cssText = '.a:hover { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover { color: white }', + '.a:hover, .a.\\:hover{color:white}', ); }); it('can correctly add hover when in middle of selector', () => { const cssText = 'ul li a:hover img { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - 'ul li a:hover img, ul li a.\\:hover img { color: white }', + 'ul li a:hover img, ul li a.\\:hover img{color:white}', ); }); @@ -111,56 +111,48 @@ ul li.specified c:hover img { color: white }`; expect(adaptCssForReplay(cssText, cache)).toEqual( - `ul li.specified a:hover img, ul li.specified a.\\:hover img, -ul li.multiline -b:hover -img, ul li.multiline -b.\\:hover -img, -ul li.specified c:hover img, ul li.specified c.\\:hover img { - color: white -}`, + `ul li.specified a:hover img, ul li.specified a.\\:hover img,ul li.multiline b:hover img, ul li.multiline b.\\:hover img,ul li.specified c:hover img, ul li.specified c.\\:hover img{color:white}`, ); }); it('can add hover class within media query', () => { const cssText = '@media screen { .m:hover { color: white } }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '@media screen { .m:hover, .m.\\:hover { color: white } }', + '@media screen{.m:hover, .m.\\:hover{color:white}}', ); }); it('can add hover class when there is multi selector', () => { const cssText = '.a, .b:hover, .c { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '.a, .b:hover, .b.\\:hover, .c { color: white }', + '.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(adaptCssForReplay(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover, .a:hover::after, .a.\\:hover::after { color: white }', + '.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(adaptCssForReplay(cssText, cache)).toEqual( - 'div:hover::after, div.\\:hover::after { color: white }', + '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(adaptCssForReplay(cssText, cache)).toEqual( - 'a:hover b:hover, a.\\:hover b.\\:hover { color: white }', + 'a:hover b:hover, a.\\:hover b.\\:hover{color:white}', ); }); it('will ignore :hover in css value', () => { const cssText = '.a::after { content: ":hover" }'; - expect(adaptCssForReplay(cssText, cache)).toEqual(cssText); + expect(adaptCssForReplay(cssText, cache)).toEqual('.a::after{content:":hover"}'); }); it('can adapt media rules to replay context', () => { From 692899d82ff68c4474555ababd00da887aacb5b9 Mon Sep 17 00:00:00 2001 From: David Newell Date: Wed, 24 Apr 2024 18:33:10 +0100 Subject: [PATCH 04/16] media changes --- packages/rrweb-snapshot/src/css.ts | 965 ------------------- packages/rrweb-snapshot/src/rebuild.ts | 11 +- packages/rrweb-snapshot/test/rebuild.test.ts | 2 +- 3 files changed, 9 insertions(+), 969 deletions(-) delete mode 100644 packages/rrweb-snapshot/src/css.ts diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts deleted file mode 100644 index 1a3157d40f..0000000000 --- a/packages/rrweb-snapshot/src/css.ts +++ /dev/null @@ -1,965 +0,0 @@ -/** - * 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. - */ -/* eslint-disable tsdoc/syntax */ - -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 NodeWithRules extends Node { - /** Array of nodes with the types rule, comment and any of the at-rule types. */ - rules: Array; -} - -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; -} - -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 NodeWithRules { - /** The part following @document. */ - document?: string; - /** The vendor prefix in @document, or undefined if there is none. */ - vendor?: string; -} - -/** - * The @font-face at-rule. - */ -export interface FontFace extends Node { - /** Array of nodes with the types declaration and comment. */ - declarations?: Array; -} - -/** - * The @host at-rule. - */ -export type Host = NodeWithRules; - -/** - * 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; -} - -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; -} - -/** - * The @media at-rule. - */ -export interface Media extends NodeWithRules { - /** The part following @media. */ - media?: string; -} - -/** - * 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; -} - -/** - * The @supports at-rule. - */ -export interface Supports extends NodeWithRules { - /** The part following @supports. */ - supports?: string; -} - -/** All at-rules. */ -export type AtRule = - | Charset - | CustomMedia - | Document - | FontFace - | Host - | Import - | KeyFrames - | Media - | Namespace - | Page - | Supports; - -/** - * A collection of rules - */ -export interface StyleRules extends NodeWithRules { - source?: string; - /** 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 = {}): Stylesheet { - /** - * 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; - } - const 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) { - 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) { - 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() { - whitespace(); - while (css[0] == '}') { - error('extra closing bracket'); - css = css.slice(1); - whitespace(); - } - - // Use match logic from https://github.com/NxtChg/pieces/blob/3eb39c8287a97632e9347a24f333d52d916bc816/js/css_parser/css_parse.js#L46C1-L47C1 - const m = match(/^(("(?:\\"|[^"])*"|'(?:\\'|[^'])*'|[^{])+)/); - if (!m) { - return; - } - - /* @fix Remove all comments from selectors - * http://ostermiller.org/findcomment.html */ - const cleanedInput = m[0] - .trim() - .replace(/\/\*([^*]|[\r\n]|(\*+([^*/]|[\r\n])))*\*\/+/g, '') - - // Handle strings by replacing commas inside them - .replace(/"(?:\\"|[^"])*"|'(?:\\'|[^'])*'/g, (m) => { - return m.replace(/,/g, '\u200C'); - }); - - // Split using a custom function and restore commas in strings - return customSplit(cleanedInput).map((s) => - s.replace(/\u200C/g, ',').trim(), - ); - } - - /** - * Split selector correctly, ensuring not to split on comma if inside (). - */ - - function customSplit(input: string) { - const result = []; - let currentSegment = ''; - let depthParentheses = 0; // Track depth of parentheses - let depthBrackets = 0; // Track depth of square brackets - let currentStringChar = null; - - for (const char of input) { - const hasStringEscape = currentSegment.endsWith('\\'); - - if (currentStringChar) { - if (currentStringChar === char && !hasStringEscape) { - currentStringChar = null; - } - } else if (char === '(') { - depthParentheses++; - } else if (char === ')') { - depthParentheses--; - } else if (char === '[') { - depthBrackets++; - } else if (char === ']') { - depthBrackets--; - } else if ('\'"'.includes(char)) { - currentStringChar = char; - } - - // Split point is a comma that is not inside parentheses or square brackets - if (char === ',' && depthParentheses === 0 && depthBrackets === 0) { - result.push(currentSegment); - currentSegment = ''; - } else { - currentSegment += char; - } - } - - // Add the last segment - if (currentSegment) { - result.push(currentSegment); - } - - return result; - } - - /** - * Parse declaration. - */ - - function declaration(): Declaration | void | never { - const pos = position(); - - // prop - // eslint-disable-next-line no-useless-escape - const propMatch = match(/^(\*?[-#\/\*\\\w]+(\[[0-9a-z_-]+\])?)\s*/); - if (!propMatch) { - return; - } - const prop = trim(propMatch[0]); - - // : - if (!match(/^:\s*/)) { - return error(`property missing ':'`); - } - - // val - // eslint-disable-next-line no-useless-escape - 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 = []; - - 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 = { 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): 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) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - addParent(v, childParent); - }); - } else if (value && typeof value === 'object') { - addParent(value as Stylesheet, childParent); - } - } - - if (isNode) { - Object.defineProperty(obj, 'parent', { - configurable: true, - writable: true, - enumerable: false, - value: parent || null, - }); - } - - return obj; -} diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index ea218a753c..cd2de02dea 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -85,6 +85,11 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { .map(node => css.generate(node)) .filter(selector => HOVER_SELECTOR.test(selector)); + const newMediaFeatures = css + .findAll(newAst, (node) => node.type === 'MediaFeature') + .map(node => css.generate(node)) + .filter(feature => MEDIA_SELECTOR.test(feature)); + const ast = parseOld(cssText, { silent: true, }); @@ -132,10 +137,10 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { return `${selector}, ${newSelector}`; }); } - if (medias.length > 0) { + if (newMediaFeatures.length > 0) { const mediaMatcher = new RegExp( - medias - .filter((media, index) => medias.indexOf(media) === index) + newMediaFeatures + .filter((media, index) => newMediaFeatures.indexOf(media) === index) .sort((a, b) => b.length - a.length) .map((media) => { return escapeRegExp(media); diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index 384bc627c1..a2b52e4f49 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -159,7 +159,7 @@ ul li.specified c:hover img { const cssText = '@media only screen and (min-device-width : 1200px) { .a { width: 10px; }}'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '@media only screen and (min-width : 1200px) { .a { width: 10px; }}', + '@media only screen and (min-width:1200px){.a{width:10px}}', ); }); From d82f6f858c5216e3b670214ac280f66b4fc986a1 Mon Sep 17 00:00:00 2001 From: David Newell Date: Wed, 24 Apr 2024 18:33:27 +0100 Subject: [PATCH 05/16] remove old css references --- packages/rrweb-snapshot/src/rebuild.ts | 44 +++++--------------------- 1 file changed, 8 insertions(+), 36 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index cd2de02dea..6941d24197 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -1,4 +1,3 @@ -import { Rule, Media, NodeWithRules, parse as parseOld } from './css'; import { serializedNodeWithId, NodeType, @@ -80,48 +79,21 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { if (cachedStyle) return cachedStyle; const newAst = css.parse(cssText); - const newSelectors = css + const selectors = css .findAll(newAst, (node) => node.type === 'Selector') .map(node => css.generate(node)) .filter(selector => HOVER_SELECTOR.test(selector)); - const newMediaFeatures = css + const mediaFeatures = css .findAll(newAst, (node) => node.type === 'MediaFeature') .map(node => css.generate(node)) .filter(feature => MEDIA_SELECTOR.test(feature)); - const ast = parseOld(cssText, { - silent: true, - }); - - if (!ast.stylesheet) { - return cssText; - } - - const selectors: string[] = []; - const medias: string[] = []; - function getSelectors(rule: Rule | Media | NodeWithRules) { - if ('selectors' in rule && rule.selectors) { - rule.selectors.forEach((selector: string) => { - if (HOVER_SELECTOR.test(selector)) { - selectors.push(selector); - } - }); - } - if ('media' in rule && rule.media && MEDIA_SELECTOR.test(rule.media)) { - medias.push(rule.media); - } - if ('rules' in rule && rule.rules) { - rule.rules.forEach(getSelectors); - } - } - getSelectors(ast.stylesheet); - let result = css.generate(newAst); - if (newSelectors.length > 0) { + if (selectors.length > 0) { const selectorMatcher = new RegExp( - newSelectors - .filter((selector, index) => newSelectors.indexOf(selector) === index) + selectors + .filter((selector, index) => selectors.indexOf(selector) === index) .sort((a, b) => b.length - a.length) .map((selector) => { return escapeRegExp(selector); @@ -137,10 +109,10 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { return `${selector}, ${newSelector}`; }); } - if (newMediaFeatures.length > 0) { + if (mediaFeatures.length > 0) { const mediaMatcher = new RegExp( - newMediaFeatures - .filter((media, index) => newMediaFeatures.indexOf(media) === index) + mediaFeatures + .filter((media, index) => mediaFeatures.indexOf(media) === index) .sort((a, b) => b.length - a.length) .map((media) => { return escapeRegExp(media); From fe241809c01be58b3417e2ffec8037a2eb414503 Mon Sep 17 00:00:00 2001 From: David Newell Date: Thu, 25 Apr 2024 12:15:32 +0100 Subject: [PATCH 06/16] better variable name --- packages/rrweb-snapshot/src/rebuild.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 6941d24197..b1a0c0e6b9 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -78,18 +78,18 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; - const newAst = css.parse(cssText); + const ast = css.parse(cssText); const selectors = css - .findAll(newAst, (node) => node.type === 'Selector') + .findAll(ast, (node) => node.type === 'Selector') .map(node => css.generate(node)) .filter(selector => HOVER_SELECTOR.test(selector)); const mediaFeatures = css - .findAll(newAst, (node) => node.type === 'MediaFeature') + .findAll(ast, (node) => node.type === 'MediaFeature') .map(node => css.generate(node)) .filter(feature => MEDIA_SELECTOR.test(feature)); - let result = css.generate(newAst); + let result = css.generate(ast); if (selectors.length > 0) { const selectorMatcher = new RegExp( selectors From b91f0302c4b093e165c25658875cc85c4d5dfe06 Mon Sep 17 00:00:00 2001 From: David Newell Date: Thu, 25 Apr 2024 16:11:55 +0100 Subject: [PATCH 07/16] use postcss and port tests --- packages/rrweb-snapshot/package.json | 7 +- packages/rrweb-snapshot/src/rebuild.ts | 131 ++++++++++--------- packages/rrweb-snapshot/test/rebuild.test.ts | 75 +++++++++-- yarn.lock | 28 +++- 4 files changed, 169 insertions(+), 72 deletions(-) diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index a0d1e9bb74..bddec40a75 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -59,9 +59,12 @@ "ts-jest": "^27.0.5", "ts-node": "^7.0.1", "tslib": "^1.9.3", - "@types/css-tree": "^2.3.7" + "@types/css-tree": "^2.3.7", + "@types/postcss-js": "^4.0.4" }, "dependencies": { - "css-tree": "^2.3.1" + "css-tree": "^2.3.1", + "postcss": "^8.4.38", + "postcss-pseudo-classes": "^0.4.0" } } diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index b1a0c0e6b9..4e8ff28f06 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -7,15 +7,18 @@ import { legacyAttributes, } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; -import type {ParseOptions, CssNode, FindFn, GenerateOptions} from 'css-tree'; +// import * from "postcss" + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires -const css: CSSTree = require('css-tree'); +const postcss = require('postcss'); -interface CSSTree { - parse: (text: string, options?: ParseOptions) => CssNode - findAll: (ast: CssNode, fn: FindFn) => CssNode[]; - generate: (ast: CssNode, options?: GenerateOptions) => string -} +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-call +const pseudoClasses = require('postcss-pseudo-classes')({ + blacklist: [], + restrictTo: ['hover'], + preserveBeforeAfter: false, + prefix: '\\:', +}); const tagMap: tagMap = { script: 'noscript', @@ -66,66 +69,74 @@ function getTagName(n: elementNode): string { } // 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 -} +// function escapeRegExp(str: string) { +// return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +// } -const MEDIA_SELECTOR = /(max|min)-device-(width|height)/; -const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); -const HOVER_SELECTOR = /([^\\]):hover/; -const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g'); +// const MEDIA_SELECTOR = /(max|min)-device-(width|height)/; +// const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); +// const HOVER_SELECTOR = /([^\\]):hover/; +// const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g'); export function adaptCssForReplay(cssText: string, cache: BuildCache): string { const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; - const ast = css.parse(cssText); - const selectors = css - .findAll(ast, (node) => node.type === 'Selector') - .map(node => css.generate(node)) - .filter(selector => HOVER_SELECTOR.test(selector)); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const ast: { css: string } = postcss([pseudoClasses]).process(cssText); + const result = ast.css; - const mediaFeatures = css - .findAll(ast, (node) => node.type === 'MediaFeature') - .map(node => css.generate(node)) - .filter(feature => MEDIA_SELECTOR.test(feature)); + // const mediaFeatures = css + // .findAll(ast, (node) => node.type === 'MediaFeature') + // .map((node) => css.generate(node)) + // .filter((feature) => MEDIA_SELECTOR.test(feature)); - let result = css.generate(ast); - if (selectors.length > 0) { - 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', - ); - result = result.replace(selectorMatcher, (selector) => { - const newSelector = selector.replace( - HOVER_SELECTOR_GLOBAL, - '$1.\\:hover', - ); - return `${selector}, ${newSelector}`; - }); - } - if (mediaFeatures.length > 0) { - const mediaMatcher = new RegExp( - mediaFeatures - .filter((media, index) => mediaFeatures.indexOf(media) === index) - .sort((a, b) => b.length - a.length) - .map((media) => { - return escapeRegExp(media); - }) - .join('|'), - 'g', - ); - result = result.replace(mediaMatcher, (media) => { - // not attempting to maintain min-device-width along with min-width - // (it's non standard) - return media.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2'); - }); - } + // const selectors: string[] = []; + // css.walk(ast, function (node) { + // if (node.type === 'Selector') { + // const selector = css.generate(node); + // if (HOVER_SELECTOR.test(selector)) { + // selectors.push(css.generate(node)); + // } + // } + // }); + + // let result = css.generate(ast); + // if (selectors.length > 0) { + // 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', + // ); + // result = result.replace(selectorMatcher, (selector) => { + // const newSelector = selector.replace( + // HOVER_SELECTOR_GLOBAL, + // '$1.\\:hover', + // ); + // return `${selector}, ${newSelector}`; + // }); + // } + // if (mediaFeatures.length > 0) { + // const mediaMatcher = new RegExp( + // mediaFeatures + // .filter((media, index) => mediaFeatures.indexOf(media) === index) + // .sort((a, b) => b.length - a.length) + // .map((media) => { + // return escapeRegExp(media); + // }) + // .join('|'), + // 'g', + // ); + // result = result.replace(mediaMatcher, (media) => { + // // not attempting to maintain min-device-width along with min-width + // // (it's non standard) + // return media.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2'); + // }); + // } cache?.stylesWithHoverClass.set(cssText, result); return result; } diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index a2b52e4f49..dce3338cb7 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -91,14 +91,16 @@ describe('rebuild', function () { it('can add hover class to css text', () => { const cssText = '.a:hover { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover{color:white}', + `.a:hover, +.a.\\:hover { color: white }`, ); }); it('can correctly add hover when in middle of selector', () => { const cssText = 'ul li a:hover img { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - 'ul li a:hover img, ul li a.\\:hover img{color:white}', + `ul li a:hover img, +ul li a.\\:hover img { color: white }`, ); }); @@ -111,48 +113,65 @@ ul li.specified c:hover img { color: white }`; expect(adaptCssForReplay(cssText, cache)).toEqual( - `ul li.specified a:hover img, ul li.specified a.\\:hover img,ul li.multiline b:hover img, ul li.multiline b.\\:hover img,ul li.specified c:hover img, ul li.specified c.\\:hover img{color:white}`, + `ul li.specified a:hover img, +ul li.multiline +b:hover +img, +ul li.specified c:hover img, +ul li.specified a.\\:hover img, +ul li.multiline b.\\:hover img, +ul li.specified c.\\:hover img { + color: white +}`, ); }); it('can add hover class within media query', () => { const cssText = '@media screen { .m:hover { color: white } }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '@media screen{.m:hover, .m.\\:hover{color:white}}', + `@media screen { .m:hover, +.m.\\:hover { color: white } }`, ); }); it('can add hover class when there is multi selector', () => { const cssText = '.a, .b:hover, .c { color: white }'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '.a,.b:hover, .b.\\:hover,.c{color:white}', + `.a, .b:hover, .c, +.b.\\:hover { 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(adaptCssForReplay(cssText, cache)).toEqual( - '.a:hover, .a.\\:hover,.a:hover::after, .a.\\:hover::after{color:white}', + `.a:hover, .a:hover::after, +.a.\\:hover, +.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(adaptCssForReplay(cssText, cache)).toEqual( - 'div:hover::after, div.\\:hover::after{color:white}', + `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(adaptCssForReplay(cssText, cache)).toEqual( - 'a:hover b:hover, a.\\:hover b.\\:hover{color:white}', + `a:hover b:hover, +a.\\:hover b.\\:hover { color: white }`, ); }); it('will ignore :hover in css value', () => { const cssText = '.a::after { content: ":hover" }'; - expect(adaptCssForReplay(cssText, cache)).toEqual('.a::after{content:":hover"}'); + expect(adaptCssForReplay(cssText, cache)).toEqual( + '.a::after { content: ":hover" }', + ); }); it('can adapt media rules to replay context', () => { @@ -163,6 +182,44 @@ ul li.specified c:hover img { ); }); + it('should allow empty property value', () => { + expect(adaptCssForReplay('p { color:; }', cache)).toEqual( + 'p { color:; }', + ); + }); + + it('should parse selector with comma nested inside ()', () => { + expect( + adaptCssForReplay( + '[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }', + cache, + ), + ) + .toEqual(`[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active), +[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled { border-color: rgb(84, 84, 84); }`); + }); + + it('parses nested commas in selectors correctly', () => { + expect( + adaptCssForReplay( + 'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) { background: red; }', + cache, + ), + ).toEqual( + 'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) { background: red; }', + ); + }); + + it('ignores comma in string', () => { + expect( + adaptCssForReplay( + 'li[attr="has,comma"] a:hover { background-color: red; }', + cache, + ), + ).toEqual(`li[attr="has,comma"] a:hover, +li[attr="has,comma"] a.\\:hover { background-color: red; }`); + }); + // this benchmark is unreliable when run in parallel with other tests it.skip('benchmark', () => { const cssText = fs.readFileSync( diff --git a/yarn.lock b/yarn.lock index 4dead9fad0..818b01dd67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3486,6 +3486,13 @@ dependencies: "@types/node" "*" +"@types/postcss-js@^4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/postcss-js/-/postcss-js-4.0.4.tgz#4f0fafd7a0508c7edd33095f8743e8cf5c28ad78" + integrity sha512-j5+GMZVIPCJpRTwI/mO64mCzv7X+zAEq3JP0EV2lo/BrLWHAohEubUJimIAY23rH27+wKce0fXUYjAdBoqlaYw== + dependencies: + postcss "^8.3.3" + "@types/prettier@^2.1.5": version "2.4.1" resolved "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.1.tgz" @@ -11123,6 +11130,11 @@ nanoid@^3.3.1, nanoid@^3.3.4: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== +nanoid@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.7.tgz#d0c301a691bc8d54efa0a2226ccf3fe2fd656bd8" + integrity sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g== + nanoid@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-4.0.0.tgz#6e144dee117609232c3f415c34b0e550e64999a5" @@ -12178,6 +12190,11 @@ postcss-ordered-values@^4.1.2: postcss "^7.0.0" postcss-value-parser "^3.0.0" +postcss-pseudo-classes@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/postcss-pseudo-classes/-/postcss-pseudo-classes-0.4.0.tgz#e3ecfff1c30db7ecda9dd063bc80499b305393f5" + integrity sha512-k1MTMzMdBg3eW05QV/W3rZyuzOFJ3HU3AMbMamillv5G/5NuepHdMl6jO+6GHnjxH12/sNmBqdOI+zYIP86kZg== + postcss-reduce-initial@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz" @@ -12278,6 +12295,15 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.27: picocolors "^0.2.1" source-map "^0.6.1" +postcss@^8.3.3, postcss@^8.4.38: + version "8.4.38" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" + integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== + dependencies: + nanoid "^3.3.7" + picocolors "^1.0.0" + source-map-js "^1.2.0" + postcss@^8.4.16, postcss@^8.4.18: version "8.4.18" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.18.tgz#6d50046ea7d3d66a85e0e782074e7203bc7fbca2" @@ -13590,7 +13616,7 @@ sorcery@^0.11.0: minimist "^1.2.0" sander "^0.5.0" -source-map-js@^1.0.1: +source-map-js@^1.0.1, source-map-js@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== From ef35af7671d5b52605bd2d5c1ee56633fb95e23a Mon Sep 17 00:00:00 2001 From: David Newell Date: Mon, 29 Apr 2024 18:57:06 +0100 Subject: [PATCH 08/16] fix media test --- packages/rrdom-nodejs/tsconfig.json | 12 +- packages/rrdom/tsconfig.json | 12 +- packages/rrweb-snapshot/src/css.ts | 20 +++ packages/rrweb-snapshot/src/rebuild.ts | 6 +- packages/rrweb-snapshot/test/css.test.ts | 147 ------------------- packages/rrweb-snapshot/test/rebuild.test.ts | 4 +- packages/rrweb/tsconfig.json | 3 - packages/types/tsconfig.json | 19 ++- 8 files changed, 52 insertions(+), 171 deletions(-) create mode 100644 packages/rrweb-snapshot/src/css.ts diff --git a/packages/rrdom-nodejs/tsconfig.json b/packages/rrdom-nodejs/tsconfig.json index 0c2f119853..622b2fdeaf 100644 --- a/packages/rrdom-nodejs/tsconfig.json +++ b/packages/rrdom-nodejs/tsconfig.json @@ -10,13 +10,18 @@ "sourceMap": true, "rootDir": "src", "outDir": "build", - "lib": ["es6", "dom"], + "lib": [ + "es6", + "dom" + ], "skipLibCheck": true, "declaration": true, "importsNotUsedAsValues": "error" }, "compileOnSave": true, - "exclude": ["test"], + "exclude": [ + "test" + ], "include": [ "src", "test.d.ts", @@ -26,9 +31,6 @@ "references": [ { "path": "../rrdom" - }, - { - "path": "../rrweb-snapshot" } ] } diff --git a/packages/rrdom/tsconfig.json b/packages/rrdom/tsconfig.json index 450e56e151..51ca091b06 100644 --- a/packages/rrdom/tsconfig.json +++ b/packages/rrdom/tsconfig.json @@ -10,21 +10,23 @@ "sourceMap": true, "rootDir": "src", "outDir": "build", - "lib": ["es6", "dom"], + "lib": [ + "es6", + "dom" + ], "skipLibCheck": true, "declaration": true, "importsNotUsedAsValues": "error" }, "references": [ - { - "path": "../rrweb-snapshot" - }, { "path": "../types" } ], "compileOnSave": true, - "exclude": ["test"], + "exclude": [ + "test" + ], "include": [ "src", "../rrweb/src/record/workers/workers.d.ts", diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts new file mode 100644 index 0000000000..124c962348 --- /dev/null +++ b/packages/rrweb-snapshot/src/css.ts @@ -0,0 +1,20 @@ +import type { Plugin } from 'postcss'; + +const MEDIA_SELECTOR = /(max|min)-device-(width|height)/; +const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); + +const creator: Plugin = { + postcssPlugin: 'postcss-custom-selectors', + prepare() { + return { + postcssPlugin: 'postcss-custom-selectors', + AtRule: function (atrule) { + if (atrule.params.match(MEDIA_SELECTOR_GLOBAL)) { + atrule.params = atrule.params.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2'); + } + }, + }; + }, +}; + +export default creator; diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 4e8ff28f06..3162d6c29a 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -1,3 +1,4 @@ +import creator from './css'; import { serializedNodeWithId, NodeType, @@ -8,6 +9,7 @@ import { } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; // import * from "postcss" +// import * from "postcss-pseudo-classes" // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const postcss = require('postcss'); @@ -82,7 +84,9 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { if (cachedStyle) return cachedStyle; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const ast: { css: string } = postcss([pseudoClasses]).process(cssText); + const ast: { css: string } = postcss([pseudoClasses, creator]).process( + cssText, + ); const result = ast.css; // const mediaFeatures = css diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index 6f10f6e569..60b17389d2 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -1,153 +1,6 @@ -import { parse, Rule, Media } from '../src/css'; import { fixSafariColons, escapeImportStatement } from './../src/utils'; 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).toEqual('booty.css'); - - const position = ast.stylesheet!.rules[0].position!; - expect(position.start).toBeTruthy(); - expect(position.end).toBeTruthy(); - expect(position.source).toEqual('booty.css'); - expect(position.content).toEqual(css); - }); - - it('should throw when a selector is missing', () => { - expect(() => { - parse('{size: large}'); - }).toThrow(); - - expect(() => { - parse('b { color: red; }\n{ color: green; }\na { color: blue; }'); - }).toThrow(); - }); - - it('should throw when a broken comment is found', () => { - expect(() => { - parse('thing { color: red; } /* b { color: blue; }'); - }).toThrow(); - - expect(() => { - parse('/*'); - }).toThrow(); - - /* Nested comments should be fine */ - expect(() => { - parse('/* /* */'); - }).not.toThrow(); - }); - - it('should allow empty property value', () => { - expect(() => { - parse('p { color:; }'); - }).not.toThrow(); - }); - - it('should not throw with silent option', () => { - expect(() => { - parse('thing { color: red; } /* b { color: blue; }', { silent: true }); - }).not.toThrow(); - }); - - 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).toBeGreaterThan(2); - - const errors = result.stylesheet!.parsingErrors!; - expect(errors.length).toEqual(2); - - expect(errors[0]).toHaveProperty('message'); - expect(errors[0]).toHaveProperty('reason'); - expect(errors[0]).toHaveProperty('filename'); - expect(errors[0]).toHaveProperty('line'); - expect(errors[0]).toHaveProperty('column'); - expect(errors[0]).toHaveProperty('source'); - expect(errors[0].filename).toEqual('foo.css'); - }); - - it('should parse selector with comma nested inside ()', () => { - const result = parse( - '[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }', - ); - - expect(result.parent).toEqual(null); - - const rules = result.stylesheet!.rules; - expect(rules.length).toEqual(1); - - let rule = rules[0] as Rule; - expect(rule.parent).toEqual(result); - expect(rule.selectors?.length).toEqual(1); - - let decl = rule.declarations![0]; - expect(decl.parent).toEqual(rule); - }); - - it('parses { and } in attribute selectors correctly', () => { - const result = parse('foo[someAttr~="{someId}"] { color: red; }'); - const rules = result.stylesheet!.rules; - - expect(rules.length).toEqual(1); - - const rule = rules[0] as Rule; - - expect(rule.selectors![0]).toEqual('foo[someAttr~="{someId}"]'); - }); - - it('should set parent property', () => { - const result = parse( - 'thing { test: value; }\n' + - '@media (min-width: 100px) { thing { test: value; } }', - ); - - expect(result.parent).toEqual(null); - - const rules = result.stylesheet!.rules; - expect(rules.length).toEqual(2); - - let rule = rules[0] as Rule; - expect(rule.parent).toEqual(result); - expect(rule.declarations!.length).toEqual(1); - - let decl = rule.declarations![0]; - expect(decl.parent).toEqual(rule); - - const media = rules[1] as Media; - expect(media.parent).toEqual(result); - expect(media.rules!.length).toEqual(1); - - rule = media.rules![0] as Rule; - expect(rule.parent).toEqual(media); - - expect(rule.declarations!.length).toEqual(1); - decl = rule.declarations![0]; - expect(decl.parent).toEqual(rule); - }); - - it('parses : in attribute selectors correctly', () => { - const out1 = fixSafariColons('[data-foo] { color: red; }'); - expect(out1).toEqual('[data-foo] { color: red; }'); - - const out2 = fixSafariColons('[data-foo:other] { color: red; }'); - expect(out2).toEqual('[data-foo\\:other] { color: red; }'); - - const out3 = fixSafariColons('[data-aa\\:other] { color: red; }'); - expect(out3).toEqual('[data-aa\\:other] { color: red; }'); - }); - it('parses nested commas in selectors correctly', () => { const result = parse( ` diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index dce3338cb7..a01920cdd5 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -176,9 +176,9 @@ a.\\:hover b.\\:hover { color: white }`, it('can adapt media rules to replay context', () => { const cssText = - '@media only screen and (min-device-width : 1200px) { .a { width: 10px; }}'; + '@media only screen and (min-device-width: 1200px) { .a { width: 10px; }}'; expect(adaptCssForReplay(cssText, cache)).toEqual( - '@media only screen and (min-width:1200px){.a{width:10px}}', + '@media only screen and (min-width: 1200px) { .a { width: 10px; }}', ); }); diff --git a/packages/rrweb/tsconfig.json b/packages/rrweb/tsconfig.json index fb1b407ad6..a70b0945f0 100644 --- a/packages/rrweb/tsconfig.json +++ b/packages/rrweb/tsconfig.json @@ -24,9 +24,6 @@ }, { "path": "../rrdom" - }, - { - "path": "../rrweb-snapshot" } ], "exclude": [ diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index ecde939d12..e83b025afb 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -10,17 +10,20 @@ "sourceMap": true, "rootDir": "src", "outDir": "build", - "lib": ["es6", "dom"], + "lib": [ + "es6", + "dom" + ], "skipLibCheck": true, "declaration": true, "importsNotUsedAsValues": "error" }, "compileOnSave": true, - "exclude": ["test"], - "include": ["src"], - "references": [ - { - "path": "../rrweb-snapshot" - } - ] + "exclude": [ + "test" + ], + "include": [ + "src" + ], + "references": [] } From 9b10e2964a4f8803a82a1b3924d13af9cca881f1 Mon Sep 17 00:00:00 2001 From: David Newell Date: Mon, 29 Apr 2024 18:57:37 +0100 Subject: [PATCH 09/16] remove old code --- packages/rrweb-snapshot/src/rebuild.ts | 62 -------------------------- 1 file changed, 62 deletions(-) diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 3162d6c29a..4a4250b1a0 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -70,15 +70,6 @@ function getTagName(n: elementNode): string { 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 MEDIA_SELECTOR = /(max|min)-device-(width|height)/; -// const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); -// const HOVER_SELECTOR = /([^\\]):hover/; -// const HOVER_SELECTOR_GLOBAL = new RegExp(HOVER_SELECTOR.source, 'g'); export function adaptCssForReplay(cssText: string, cache: BuildCache): string { const cachedStyle = cache?.stylesWithHoverClass.get(cssText); if (cachedStyle) return cachedStyle; @@ -88,59 +79,6 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { cssText, ); const result = ast.css; - - // const mediaFeatures = css - // .findAll(ast, (node) => node.type === 'MediaFeature') - // .map((node) => css.generate(node)) - // .filter((feature) => MEDIA_SELECTOR.test(feature)); - - // const selectors: string[] = []; - // css.walk(ast, function (node) { - // if (node.type === 'Selector') { - // const selector = css.generate(node); - // if (HOVER_SELECTOR.test(selector)) { - // selectors.push(css.generate(node)); - // } - // } - // }); - - // let result = css.generate(ast); - // if (selectors.length > 0) { - // 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', - // ); - // result = result.replace(selectorMatcher, (selector) => { - // const newSelector = selector.replace( - // HOVER_SELECTOR_GLOBAL, - // '$1.\\:hover', - // ); - // return `${selector}, ${newSelector}`; - // }); - // } - // if (mediaFeatures.length > 0) { - // const mediaMatcher = new RegExp( - // mediaFeatures - // .filter((media, index) => mediaFeatures.indexOf(media) === index) - // .sort((a, b) => b.length - a.length) - // .map((media) => { - // return escapeRegExp(media); - // }) - // .join('|'), - // 'g', - // ); - // result = result.replace(mediaMatcher, (media) => { - // // not attempting to maintain min-device-width along with min-width - // // (it's non standard) - // return media.replace(MEDIA_SELECTOR_GLOBAL, '$1-$2'); - // }); - // } cache?.stylesWithHoverClass.set(cssText, result); return result; } From 5b23dde5c89cb11b4b1a6097a62fc7930557de8c Mon Sep 17 00:00:00 2001 From: David Newell Date: Tue, 30 Apr 2024 10:58:38 +0100 Subject: [PATCH 10/16] inline plugins --- packages/rrweb-snapshot/package.json | 3 +- packages/rrweb-snapshot/src/css.ts | 76 +++++++++++++++++++++++++- packages/rrweb-snapshot/src/rebuild.ts | 18 ++---- yarn.lock | 12 +--- 4 files changed, 80 insertions(+), 29 deletions(-) diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index bddec40a75..3d1a3b2b61 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -64,7 +64,6 @@ }, "dependencies": { "css-tree": "^2.3.1", - "postcss": "^8.4.38", - "postcss-pseudo-classes": "^0.4.0" + "postcss": "^8.4.38" } } diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts index 124c962348..b215cc7ef5 100644 --- a/packages/rrweb-snapshot/src/css.ts +++ b/packages/rrweb-snapshot/src/css.ts @@ -1,9 +1,9 @@ -import type { Plugin } from 'postcss'; +import type { Plugin, Rule } from 'postcss'; const MEDIA_SELECTOR = /(max|min)-device-(width|height)/; const MEDIA_SELECTOR_GLOBAL = new RegExp(MEDIA_SELECTOR.source, 'g'); -const creator: Plugin = { +const mediaSelectorPlugin: Plugin = { postcssPlugin: 'postcss-custom-selectors', prepare() { return { @@ -17,4 +17,74 @@ const creator: Plugin = { }, }; -export default creator; +// Adapted from https://github.com/giuseppeg/postcss-pseudo-classes/blob/master/index.js +const pseudoClassPlugin: Plugin = { + postcssPlugin: 'postcss-hover-classes', + prepare: function () { + const fixed: Rule[] = []; + return { + Rule: function (rule) { + if (fixed.indexOf(rule) !== -1) { + return; + } + fixed.push(rule); + + rule.selectors.forEach(function (selector) { + if (!selector.includes(':')) { + return; + } + + const selectorParts = selector.split(' '); + const pseudoedSelectorParts: string[] = []; + + selectorParts.forEach(function (selectorPart) { + const pseudos = selectorPart.match(/::?([^:]+)/g); + + if (!pseudos) { + pseudoedSelectorParts.push(selectorPart); + return; + } + + const baseSelector = selectorPart.substr( + 0, + selectorPart.length - pseudos.join('').length, + ); + + const classPseudos = pseudos.map(function (pseudo) { + const pseudoToCheck = pseudo.replace(/\(.*/g, ''); + if (pseudoToCheck !== ':hover') { + return pseudo; + } + + // Ignore pseudo-elements! + if (pseudo.match(/^::/)) { + return pseudo; + } + + // Kill the colon + pseudo = pseudo.substr(1); + + // Replace left and right parens + pseudo = pseudo.replace(/\(/g, '\\('); + pseudo = pseudo.replace(/\)/g, '\\)'); + + return '.' + '\\:' + pseudo; + }); + + pseudoedSelectorParts.push(baseSelector + classPseudos.join('')); + }); + + addSelector(pseudoedSelectorParts.join(' ')); + + function addSelector(newSelector: string) { + if (newSelector && newSelector !== selector) { + rule.selector += ',\n' + newSelector; + } + } + }); + }, + }; + }, +}; + +export { mediaSelectorPlugin, pseudoClassPlugin }; diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index 4a4250b1a0..f5ee9cb9d3 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -1,4 +1,4 @@ -import creator from './css'; +import { mediaSelectorPlugin, pseudoClassPlugin } from './css'; import { serializedNodeWithId, NodeType, @@ -9,19 +9,10 @@ import { } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; // import * from "postcss" -// import * from "postcss-pseudo-classes" // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const postcss = require('postcss'); -// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-call -const pseudoClasses = require('postcss-pseudo-classes')({ - blacklist: [], - restrictTo: ['hover'], - preserveBeforeAfter: false, - prefix: '\\:', -}); - const tagMap: tagMap = { script: 'noscript', // camel case svg element tag names @@ -75,9 +66,10 @@ export function adaptCssForReplay(cssText: string, cache: BuildCache): string { if (cachedStyle) return cachedStyle; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call - const ast: { css: string } = postcss([pseudoClasses, creator]).process( - cssText, - ); + const ast: { css: string } = postcss([ + mediaSelectorPlugin, + pseudoClassPlugin, + ]).process(cssText); const result = ast.css; cache?.stylesWithHoverClass.set(cssText, result); return result; diff --git a/yarn.lock b/yarn.lock index 818b01dd67..43c13009d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5584,15 +5584,10 @@ csso@^4.0.2: dependencies: css-tree "^1.1.2" -cssom@^0.4.4, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": +cssom@^0.4.4, cssom@^0.5.0, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": version "0.6.0" resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" -cssom@^0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" - integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== - cssom@~0.3.6: version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" @@ -12190,11 +12185,6 @@ postcss-ordered-values@^4.1.2: postcss "^7.0.0" postcss-value-parser "^3.0.0" -postcss-pseudo-classes@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/postcss-pseudo-classes/-/postcss-pseudo-classes-0.4.0.tgz#e3ecfff1c30db7ecda9dd063bc80499b305393f5" - integrity sha512-k1MTMzMdBg3eW05QV/W3rZyuzOFJ3HU3AMbMamillv5G/5NuepHdMl6jO+6GHnjxH12/sNmBqdOI+zYIP86kZg== - postcss-reduce-initial@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-4.0.3.tgz" From b011a3edb9c92dc94c4a049c64d5a6ab7e90cc53 Mon Sep 17 00:00:00 2001 From: David Newell Date: Tue, 30 Apr 2024 11:18:26 +0100 Subject: [PATCH 11/16] fix failing multiline selector --- packages/rrweb-snapshot/src/css.ts | 2 +- packages/rrweb-snapshot/src/rebuild.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts index b215cc7ef5..bc3dfb1435 100644 --- a/packages/rrweb-snapshot/src/css.ts +++ b/packages/rrweb-snapshot/src/css.ts @@ -34,7 +34,7 @@ const pseudoClassPlugin: Plugin = { return; } - const selectorParts = selector.split(' '); + const selectorParts = selector.replace(/\n/g, ' ').split(' '); const pseudoedSelectorParts: string[] = []; selectorParts.forEach(function (selectorPart) { diff --git a/packages/rrweb-snapshot/src/rebuild.ts b/packages/rrweb-snapshot/src/rebuild.ts index f5ee9cb9d3..8228fa8681 100644 --- a/packages/rrweb-snapshot/src/rebuild.ts +++ b/packages/rrweb-snapshot/src/rebuild.ts @@ -8,7 +8,6 @@ import { legacyAttributes, } from './types'; import { isElement, Mirror, isNodeMetaEqual } from './utils'; -// import * from "postcss" // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-var-requires const postcss = require('postcss'); From bd8bb5ee39f88215e8cb983bd2f7bfbd1cfcf1f0 Mon Sep 17 00:00:00 2001 From: David Newell Date: Tue, 30 Apr 2024 11:32:45 +0100 Subject: [PATCH 12/16] correct test result --- packages/rrweb-snapshot/test/rebuild.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index a01920cdd5..dfd937f87d 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -196,7 +196,7 @@ a.\\:hover b.\\:hover { color: white }`, ), ) .toEqual(`[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active), -[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled { border-color: rgb(84, 84, 84); }`); +[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }`); }); it('parses nested commas in selectors correctly', () => { From 77fa5cb0d0c1bc69a8dd1f9fd09bd9bc63fa518c Mon Sep 17 00:00:00 2001 From: David Newell Date: Tue, 30 Apr 2024 11:41:03 +0100 Subject: [PATCH 13/16] move tests to correct file --- packages/rrweb-snapshot/test/css.test.ts | 60 ------------------- packages/rrweb-snapshot/test/utils.test.ts | 70 +++++++++++++++++++++- 2 files changed, 69 insertions(+), 61 deletions(-) diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index 60b17389d2..2a618a83e0 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -44,64 +44,4 @@ li[attr="has,comma"] a:hover { (commainstrresult.stylesheet!.rules[0] as Rule)!.selectors!.length, ).toEqual(1); }); - - it('parses imports with quotes correctly', () => { - const out1 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"");`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: null, - supportsText: null, - } as unknown as CSSImportRule); - expect(out1).toEqual(`@import url("/foo.css;900;800\\"");`); - - const out2 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"") supports(display: flex);`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: null, - supportsText: 'display: flex', - } as unknown as CSSImportRule); - expect(out2).toEqual( - `@import url("/foo.css;900;800\\"") supports(display: flex);`, - ); - - const out3 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"");`, - href: '/foo.css;900;800"', - media: { - length: 1, - mediaText: 'print, screen', - }, - layerName: null, - supportsText: null, - } as unknown as CSSImportRule); - expect(out3).toEqual(`@import url("/foo.css;900;800\\"") print, screen;`); - - const out4 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"") layer(layer-1);`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: 'layer-1', - supportsText: null, - } as unknown as CSSImportRule); - expect(out4).toEqual(`@import url("/foo.css;900;800\\"") layer(layer-1);`); - - const out5 = escapeImportStatement({ - cssText: `@import url("/foo.css;900;800"") layer;`, - href: '/foo.css;900;800"', - media: { - length: 0, - }, - layerName: '', - supportsText: null, - } as unknown as CSSImportRule); - expect(out5).toEqual(`@import url("/foo.css;900;800\\"") layer;`); - }); }); diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index afbdda2f42..98214bc143 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -2,7 +2,11 @@ * @jest-environment jsdom */ import { NodeType, serializedNode } from '../src/types'; -import { extractFileExtension, isNodeMetaEqual } from '../src/utils'; +import { + escapeImportStatement, + extractFileExtension, + isNodeMetaEqual, +} from '../src/utils'; import { serializedNodeWithId } from 'rrweb-snapshot'; describe('utils', () => { @@ -198,4 +202,68 @@ describe('utils', () => { expect(extension).toBe('js'); }); }); + + describe('escapeImportStatement', () => { + it('parses imports with quotes correctly', () => { + const out1 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"");`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: null, + supportsText: null, + } as unknown as CSSImportRule); + expect(out1).toEqual(`@import url("/foo.css;900;800\\"");`); + + const out2 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"") supports(display: flex);`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: null, + supportsText: 'display: flex', + } as unknown as CSSImportRule); + expect(out2).toEqual( + `@import url("/foo.css;900;800\\"") supports(display: flex);`, + ); + + const out3 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"");`, + href: '/foo.css;900;800"', + media: { + length: 1, + mediaText: 'print, screen', + }, + layerName: null, + supportsText: null, + } as unknown as CSSImportRule); + expect(out3).toEqual(`@import url("/foo.css;900;800\\"") print, screen;`); + + const out4 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"") layer(layer-1);`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: 'layer-1', + supportsText: null, + } as unknown as CSSImportRule); + expect(out4).toEqual( + `@import url("/foo.css;900;800\\"") layer(layer-1);`, + ); + + const out5 = escapeImportStatement({ + cssText: `@import url("/foo.css;900;800"") layer;`, + href: '/foo.css;900;800"', + media: { + length: 0, + }, + layerName: '', + supportsText: null, + } as unknown as CSSImportRule); + expect(out5).toEqual(`@import url("/foo.css;900;800\\"") layer;`); + }); + }); }); From 77cd5e4e059252c69060eec904f5b8a2ea9eb4a4 Mon Sep 17 00:00:00 2001 From: David Newell Date: Tue, 30 Apr 2024 12:16:01 +0100 Subject: [PATCH 14/16] cleanup all tests --- packages/rrweb-snapshot/test/css.test.ts | 122 ++++++++++++------- packages/rrweb-snapshot/test/rebuild.test.ts | 42 +------ packages/rrweb-snapshot/test/utils.test.ts | 13 ++ 3 files changed, 97 insertions(+), 80 deletions(-) diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index 2a618a83e0..841a87eaa0 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -1,47 +1,83 @@ -import { fixSafariColons, escapeImportStatement } from './../src/utils'; +import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css'; +import { type Plugin } from 'postcss'; +const postcss = require('postcss'); describe('css parser', () => { - it('parses nested commas in selectors correctly', () => { - const result = parse( - ` -body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) { - background: red; -} -`, - ); - expect((result.stylesheet!.rules[0] as Rule)!.selectors!.length).toEqual(1); - - const trickresult = parse( - ` -li[attr="weirdly("] a:hover, li[attr="weirdly)"] a { - background-color: red; -} -`, - ); - expect( - (trickresult.stylesheet!.rules[0] as Rule)!.selectors!.length, - ).toEqual(2); - - const weirderresult = parse( - ` -li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a { - background-color: red; -} -`, - ); - expect( - (weirderresult.stylesheet!.rules[0] as Rule)!.selectors!.length, - ).toEqual(2); - - const commainstrresult = parse( - ` -li[attr="has,comma"] a:hover { - background-color: red; -} -`, - ); - expect( - (commainstrresult.stylesheet!.rules[0] as Rule)!.selectors!.length, - ).toEqual(1); + function parse(plugin: Plugin, input: string): string { + const ast = postcss([plugin]).process(input, {}); + return ast.css; + } + + describe('mediaSelectorPlugin', () => { + it('selectors without device remain unchanged', () => { + const cssText = + '@media only screen and (min-width: 1200px) { .a { width: 10px; }}'; + expect(parse(mediaSelectorPlugin, cssText)).toEqual(cssText); + }); + + it('can adapt media rules to replay context', () => { + [ + ['min', 'width'], + ['min', 'height'], + ['max', 'width'], + ['max', 'height'], + ].forEach(([first, second]) => { + expect( + parse( + mediaSelectorPlugin, + `@media only screen and (${first}-device-${second}: 1200px) { .a { width: 10px; }}`, + ), + ).toEqual( + `@media only screen and (${first}-${second}: 1200px) { .a { width: 10px; }}`, + ); + }); + expect( + parse( + mediaSelectorPlugin, + '@media only screen and (min-device-width: 1200px) { .a { width: 10px; }}', + ), + ).toEqual( + `@media only screen and (min-width: 1200px) { .a { width: 10px; }}`, + ); + }); + }); + + describe('pseudoClassPlugin', () => { + it('parses nested commas in selectors correctly', () => { + const cssText = + 'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) {background: red;}'; + expect(parse(pseudoClassPlugin, cssText)).toEqual(cssText); + }); + + it('should parse selector with comma nested inside ()', () => { + const cssText = + '[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }'; + expect(parse(pseudoClassPlugin, cssText)) + .toEqual(`[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active), +[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }`); + }); + + it('ignores ( in strings', () => { + const cssText = + 'li[attr="weirdly("] a:hover, li[attr="weirdly)"] a {background-color: red;}'; + expect(parse(pseudoClassPlugin, cssText)) + .toEqual(`li[attr="weirdly("] a:hover, li[attr="weirdly)"] a, +li[attr="weirdly("] a.\\:hover {background-color: red;}`); + }); + + it('ignores escaping in strings', () => { + const cssText = `li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a {background-color: red;}`; + expect(parse(pseudoClassPlugin, cssText)) + .toEqual(`li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a, +li[attr="weirder\\"("] a.\\:hover {background-color: red;}`); + }); + + it('ignores comma in string', () => { + const cssText = 'li[attr="has,comma"] a:hover {background: red;}'; + expect(parse(pseudoClassPlugin, cssText)).toEqual( + `li[attr="has,comma"] a:hover, +li[attr="has,comma"] a.\\:hover {background: red;}`, + ); + }); }); }); diff --git a/packages/rrweb-snapshot/test/rebuild.test.ts b/packages/rrweb-snapshot/test/rebuild.test.ts index dfd937f87d..1140b0e1ec 100644 --- a/packages/rrweb-snapshot/test/rebuild.test.ts +++ b/packages/rrweb-snapshot/test/rebuild.test.ts @@ -174,52 +174,20 @@ a.\\:hover b.\\:hover { color: white }`, ); }); - it('can adapt media rules to replay context', () => { - const cssText = - '@media only screen and (min-device-width: 1200px) { .a { width: 10px; }}'; - expect(adaptCssForReplay(cssText, cache)).toEqual( - '@media only screen and (min-width: 1200px) { .a { width: 10px; }}', - ); - }); - it('should allow empty property value', () => { expect(adaptCssForReplay('p { color:; }', cache)).toEqual( 'p { color:; }', ); }); - it('should parse selector with comma nested inside ()', () => { - expect( - adaptCssForReplay( - '[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }', - cache, - ), - ) - .toEqual(`[_nghost-ng-c4172599085]:not(.fit-content).aim-select:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active), -[_nghost-ng-c4172599085]:not(.fit-content).aim-select.\\:hover:not(:disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--disabled, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--invalid, [_nghost-ng-c4172599085]:not(.fit-content).aim-select--active) { border-color: rgb(84, 84, 84); }`); - }); - - it('parses nested commas in selectors correctly', () => { - expect( - adaptCssForReplay( - 'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) { background: red; }', - cache, - ), - ).toEqual( - 'body > ul :is(li:not(:first-of-type) a:hover, li:not(:first-of-type).active a) { background: red; }', + it('can adapt media rules to replay context', () => { + const cssText = + '@media only screen and (min-device-width : 1200px) { .a { width: 10px; }}'; + expect(adaptCssForReplay(cssText, cache)).toEqual( + '@media only screen and (min-width : 1200px) { .a { width: 10px; }}', ); }); - it('ignores comma in string', () => { - expect( - adaptCssForReplay( - 'li[attr="has,comma"] a:hover { background-color: red; }', - cache, - ), - ).toEqual(`li[attr="has,comma"] a:hover, -li[attr="has,comma"] a.\\:hover { background-color: red; }`); - }); - // this benchmark is unreliable when run in parallel with other tests it.skip('benchmark', () => { const cssText = fs.readFileSync( diff --git a/packages/rrweb-snapshot/test/utils.test.ts b/packages/rrweb-snapshot/test/utils.test.ts index 98214bc143..ed4b3a0e61 100644 --- a/packages/rrweb-snapshot/test/utils.test.ts +++ b/packages/rrweb-snapshot/test/utils.test.ts @@ -5,6 +5,7 @@ import { NodeType, serializedNode } from '../src/types'; import { escapeImportStatement, extractFileExtension, + fixSafariColons, isNodeMetaEqual, } from '../src/utils'; import { serializedNodeWithId } from 'rrweb-snapshot'; @@ -266,4 +267,16 @@ describe('utils', () => { expect(out5).toEqual(`@import url("/foo.css;900;800\\"") layer;`); }); }); + describe('fixSafariColons', () => { + it('parses : in attribute selectors correctly', () => { + const out1 = fixSafariColons('[data-foo] { color: red; }'); + expect(out1).toEqual('[data-foo] { color: red; }'); + + const out2 = fixSafariColons('[data-foo:other] { color: red; }'); + expect(out2).toEqual('[data-foo\\:other] { color: red; }'); + + const out3 = fixSafariColons('[data-aa\\:other] { color: red; }'); + expect(out3).toEqual('[data-aa\\:other] { color: red; }'); + }); + }); }); From 4a4824766ec2c1befade68b17029694c7e3e22bc Mon Sep 17 00:00:00 2001 From: David Newell Date: Tue, 30 Apr 2024 12:17:25 +0100 Subject: [PATCH 15/16] remove unused css-tree --- packages/rrweb-snapshot/package.json | 4 +-- yarn.lock | 40 ++++++++-------------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/packages/rrweb-snapshot/package.json b/packages/rrweb-snapshot/package.json index 3d1a3b2b61..718ffd3b7c 100644 --- a/packages/rrweb-snapshot/package.json +++ b/packages/rrweb-snapshot/package.json @@ -1,6 +1,6 @@ { "name": "rrweb-snapshot", - "version": "2.0.0-alpha.12", + "version": "2.0.0-alpha.13", "description": "rrweb's component to take a snapshot of DOM, aka DOM serializer", "scripts": { "prepare": "npm run prepack", @@ -59,11 +59,9 @@ "ts-jest": "^27.0.5", "ts-node": "^7.0.1", "tslib": "^1.9.3", - "@types/css-tree": "^2.3.7", "@types/postcss-js": "^4.0.4" }, "dependencies": { - "css-tree": "^2.3.1", "postcss": "^8.4.38" } } diff --git a/yarn.lock b/yarn.lock index 43c13009d1..9b812376d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3277,11 +3277,6 @@ resolved "https://registry.npmjs.org/@types/css-font-loading-module/-/css-font-loading-module-0.0.7.tgz" integrity sha512-nl09VhutdjINdWyXxHWN/w9zlNCfr60JUqJbd24YXUuCwgeL0TpFSdElCwb6cxfB6ybE19Gjj4g0jsgkXxKv1Q== -"@types/css-tree@^2.3.7": - version "2.3.7" - resolved "https://registry.yarnpkg.com/@types/css-tree/-/css-tree-2.3.7.tgz#02818035f0d9cf88c2e6983c838526e5624bf189" - integrity sha512-LUlutQBpR2TgqZJdvXCPOx9EME7a4PHSEo2Y2c8POFpj1E9a6V94PUZNwjVdfHWyb8RQZoNHTYOKs980+sOi+g== - "@types/cssom@^0.4.1": version "0.4.1" resolved "https://registry.npmjs.org/@types/cssom/-/cssom-0.4.1.tgz#fb64e145b425bd6c1b0ed78ebd66ba43b6e088ab" @@ -5486,14 +5481,6 @@ css-tree@^1.0.0-alpha.39, css-tree@^1.1.2: mdn-data "2.0.14" source-map "^0.6.1" -css-tree@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-2.3.1.tgz#10264ce1e5442e8572fc82fbe490644ff54b5c20" - integrity sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw== - dependencies: - mdn-data "2.0.30" - source-map-js "^1.0.1" - css-what@^3.2.1: version "3.4.2" resolved "https://registry.npmjs.org/css-what/-/css-what-3.4.2.tgz" @@ -5584,10 +5571,15 @@ csso@^4.0.2: dependencies: css-tree "^1.1.2" -cssom@^0.4.4, cssom@^0.5.0, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": +cssom@^0.4.4, "cssom@https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz": version "0.6.0" resolved "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" +cssom@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.5.0.tgz#d254fa92cd8b6fbd83811b9fbaed34663cc17c36" + integrity sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw== + cssom@~0.3.6: version "0.3.8" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.8.tgz#9f1276f5b2b463f2114d3f2c75250af8c1a36f4a" @@ -10822,11 +10814,6 @@ mdn-data@2.0.14: resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz" integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== -mdn-data@2.0.30: - version "2.0.30" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.30.tgz#ce4df6f80af6cfbe218ecd5c552ba13c4dfa08cc" - integrity sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA== - mdn-data@2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.4.tgz" @@ -13199,11 +13186,6 @@ rollup@~2.78.0: optionalDependencies: fsevents "~2.3.2" -rrweb-snapshot@^2.0.0-alpha.13: - version "2.0.0-alpha.13" - resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-2.0.0-alpha.13.tgz#25097ae71ca4ac6c66edf3cae754607886f689e2" - integrity sha512-slbhNBCYjxLGCeH95a67ECCy5a22nloXp1F5wF7DCzUNw80FN7tF9Lef1sRGLNo32g3mNqTc2sWLATlKejMxYw== - run-async@^2.4.0: version "2.4.1" resolved "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz" @@ -13606,16 +13588,16 @@ sorcery@^0.11.0: minimist "^1.2.0" sander "^0.5.0" -source-map-js@^1.0.1, source-map-js@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" - integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== - source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== +source-map-js@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af" + integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg== + source-map-support@0.5.13: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" From 6353f2de44b2995bdae3a3524a3c8f64201b8603 Mon Sep 17 00:00:00 2001 From: Billy Vong Date: Tue, 4 Jun 2024 14:07:23 -0400 Subject: [PATCH 16/16] undo whitespace --- packages/rrdom-nodejs/tsconfig.json | 12 +++++------- packages/rrdom/tsconfig.json | 12 +++++------- packages/rrweb/tsconfig.json | 3 +++ packages/types/tsconfig.json | 19 ++++++++----------- 4 files changed, 21 insertions(+), 25 deletions(-) diff --git a/packages/rrdom-nodejs/tsconfig.json b/packages/rrdom-nodejs/tsconfig.json index 622b2fdeaf..0c2f119853 100644 --- a/packages/rrdom-nodejs/tsconfig.json +++ b/packages/rrdom-nodejs/tsconfig.json @@ -10,18 +10,13 @@ "sourceMap": true, "rootDir": "src", "outDir": "build", - "lib": [ - "es6", - "dom" - ], + "lib": ["es6", "dom"], "skipLibCheck": true, "declaration": true, "importsNotUsedAsValues": "error" }, "compileOnSave": true, - "exclude": [ - "test" - ], + "exclude": ["test"], "include": [ "src", "test.d.ts", @@ -31,6 +26,9 @@ "references": [ { "path": "../rrdom" + }, + { + "path": "../rrweb-snapshot" } ] } diff --git a/packages/rrdom/tsconfig.json b/packages/rrdom/tsconfig.json index 51ca091b06..450e56e151 100644 --- a/packages/rrdom/tsconfig.json +++ b/packages/rrdom/tsconfig.json @@ -10,23 +10,21 @@ "sourceMap": true, "rootDir": "src", "outDir": "build", - "lib": [ - "es6", - "dom" - ], + "lib": ["es6", "dom"], "skipLibCheck": true, "declaration": true, "importsNotUsedAsValues": "error" }, "references": [ + { + "path": "../rrweb-snapshot" + }, { "path": "../types" } ], "compileOnSave": true, - "exclude": [ - "test" - ], + "exclude": ["test"], "include": [ "src", "../rrweb/src/record/workers/workers.d.ts", diff --git a/packages/rrweb/tsconfig.json b/packages/rrweb/tsconfig.json index a70b0945f0..fb1b407ad6 100644 --- a/packages/rrweb/tsconfig.json +++ b/packages/rrweb/tsconfig.json @@ -24,6 +24,9 @@ }, { "path": "../rrdom" + }, + { + "path": "../rrweb-snapshot" } ], "exclude": [ diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json index e83b025afb..ecde939d12 100644 --- a/packages/types/tsconfig.json +++ b/packages/types/tsconfig.json @@ -10,20 +10,17 @@ "sourceMap": true, "rootDir": "src", "outDir": "build", - "lib": [ - "es6", - "dom" - ], + "lib": ["es6", "dom"], "skipLibCheck": true, "declaration": true, "importsNotUsedAsValues": "error" }, "compileOnSave": true, - "exclude": [ - "test" - ], - "include": [ - "src" - ], - "references": [] + "exclude": ["test"], + "include": ["src"], + "references": [ + { + "path": "../rrweb-snapshot" + } + ] }