Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: fix css parsing in replayer #195

Merged
merged 3 commits into from
Jun 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions .changeset/rotten-spies-enjoy.md

This file was deleted.

165 changes: 97 additions & 68 deletions packages/rrweb-snapshot/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,6 @@ export interface Node {
};
}

export interface NodeWithRules extends Node {
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules: Array<Rule | Comment | AtRule>;
}

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[];
Expand Down Expand Up @@ -103,11 +98,13 @@ export interface CustomMedia extends Node {
/**
* The @document at-rule.
*/
export interface Document extends NodeWithRules {
export interface Document extends Node {
/** The part following @document. */
document?: string;
/** The vendor prefix in @document, or undefined if there is none. */
vendor?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}

/**
Expand All @@ -121,7 +118,10 @@ export interface FontFace extends Node {
/**
* The @host at-rule.
*/
export type Host = NodeWithRules;
export interface Host extends Node {
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}

/**
* The @import at-rule.
Expand Down Expand Up @@ -153,9 +153,11 @@ export interface KeyFrame extends Node {
/**
* The @media at-rule.
*/
export interface Media extends NodeWithRules {
export interface Media extends Node {
/** The part following @media. */
media?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}

/**
Expand All @@ -179,9 +181,11 @@ export interface Page extends Node {
/**
* The @supports at-rule.
*/
export interface Supports extends NodeWithRules {
export interface Supports extends Node {
/** The part following @supports. */
supports?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules?: Array<Rule | Comment | AtRule>;
}

/** All at-rules. */
Expand All @@ -201,8 +205,10 @@ export type AtRule =
/**
* A collection of rules
*/
export interface StyleRules extends NodeWithRules {
export interface StyleRules {
source?: string;
/** Array of nodes with the types rule, comment and any of the at-rule types. */
rules: Array<Rule | Comment | AtRule>;
/** Array of Errors. Errors collected during parsing when option silent is true. */
parsingErrors?: ParserError[];
}
Expand All @@ -218,7 +224,7 @@ export interface Stylesheet extends Node {
// https://github.com/visionmedia/css-parse/pull/49#issuecomment-30088027
const commentre = /\/\*[^*]*\*+([^/*][^*]*\*+)*\//g;

export function parse(css: string, options: ParserOptions = {}): Stylesheet {
export function parse(css: string, options: ParserOptions = {}) {
/**
* Positional.
*/
Expand Down Expand Up @@ -425,81 +431,104 @@ export function parse(css: string, options: ParserOptions = {}): Stylesheet {
*/

function selector() {
whitespace();
while (css[0] == '}') {
error('extra closing bracket');
css = css.slice(1);
whitespace();
}
const m = match(/^([^{]+)/);

// 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()
const splitSelectors = trim(m[0])
.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 ().
*/
})
.split(/\s*(?![^(]*\)),\s*/);

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;
if (splitSelectors.length <= 1) {
return splitSelectors.map((s) => {
return s.replace(/\u200C/g, ',');
});
}

for (const char of input) {
const hasStringEscape = currentSegment.endsWith('\\');
// For each selector, need to check if we properly split on `,`
// Example case where selector is:
// .bar:has(input:is(:disabled), button:is(:disabled))
let i = 0;
let j = 0;
const len = splitSelectors.length;
const finalSelectors = [];
while (i < len) {
// Look for selectors with opening parens - `(` and search rest of
// selectors for the first one with matching number of closing
// parens `)`
const openingParensCount = (splitSelectors[i].match(/\(/g) || []).length;
const closingParensCount = (splitSelectors[i].match(/\)/g) || []).length;
let unbalancedParens = openingParensCount - closingParensCount;

if (unbalancedParens >= 1) {
// At least one opening parens was found, prepare to look through
// rest of selectors
let foundClosingSelector = false;

// Loop starting with next item in array, until we find matching
// number of ending parens
j = i + 1;
while (j < len) {
// peek into next item to count the number of closing brackets
const nextOpeningParensCount = (splitSelectors[j].match(/\(/g) || [])
.length;
const nextClosingParensCount = (splitSelectors[j].match(/\)/g) || [])
.length;
const nextUnbalancedParens =
nextClosingParensCount - nextOpeningParensCount;

if (nextUnbalancedParens === unbalancedParens) {
// Matching # of closing parens was found, join all elements
// from i to j
finalSelectors.push(splitSelectors.slice(i, j + 1).join(','));

// we will want to skip the items that we have joined together
i = j + 1;

// Use to continue the outer loop
foundClosingSelector = true;

// break out of inner loop so we found matching closing parens
break;
}

// No matching closing parens found, keep moving through index, but
// update the # of unbalanced parents still outstanding
j++;
unbalancedParens -= nextUnbalancedParens;
}

if (currentStringChar) {
if (currentStringChar === char && !hasStringEscape) {
currentStringChar = null;
if (foundClosingSelector) {
// Matching closing selector was found, move to next selector
continue;
}
} 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;
// No matching closing selector was found, either invalid CSS,
// or unbalanced number of opening parens were used as CSS
// selectors. Assume that rest of the list of selectors are
// selectors and break to avoid iterating through the list of
// selectors again.
splitSelectors
.slice(i, len)
.forEach((selector) => selector && finalSelectors.push(selector));
break;
}
}

// Add the last segment
if (currentSegment) {
result.push(currentSegment);
// No opening parens found, contiue looking through list
splitSelectors[i] && finalSelectors.push(splitSelectors[i]);
i++;
}

return result;
return finalSelectors.map((s) => {
return s.replace(/\u200C/g, ',');
});
}

/**
Expand Down Expand Up @@ -936,7 +965,7 @@ function trim(str: string) {
* Adds non-enumerable parent node reference to each node.
*/

function addParent(obj: Stylesheet, parent?: Stylesheet): Stylesheet {
function addParent(obj: Stylesheet, parent?: Stylesheet) {
const isNode = obj && typeof obj.type === 'string';
const childParent = isNode ? obj : parent;

Expand Down
4 changes: 2 additions & 2 deletions packages/rrweb-snapshot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import snapshot, {
} from './snapshot';
import rebuild, {
buildNodeWithSN,
adaptCssForReplay,
addHoverClass,
createCache,
} from './rebuild';
export * from './types';
Expand All @@ -24,7 +24,7 @@ export {
serializeNodeWithId,
rebuild,
buildNodeWithSN,
adaptCssForReplay,
addHoverClass,
createCache,
transformAttribute,
ignoreAttribute,
Expand Down
Loading
Loading