From 8053361d17956bfa4f323027ae2504206a7d2cbf Mon Sep 17 00:00:00 2001 From: David Newell Date: Thu, 18 Apr 2024 23:46:17 +0100 Subject: [PATCH] better splitting of selectors (#1440) * better splitting of selectors - overlapping with #1401 * Add test from example at https://github.com/PostHog/posthog/pull/21427 * ignore brackets inside selector strings * Add another test as noticed that it's possible to escape strings * Ensure we are ignoring commas within strings Co-authored-by: Eoghan Murray --- .changeset/modern-doors-watch.md | 5 + packages/rrweb-snapshot/src/css.ts | 60 ++++++++++- packages/rrweb-snapshot/test/css.test.ts | 123 +++++++++++++++++++++++ 3 files changed, 183 insertions(+), 5 deletions(-) create mode 100644 .changeset/modern-doors-watch.md diff --git a/.changeset/modern-doors-watch.md b/.changeset/modern-doors-watch.md new file mode 100644 index 0000000000..b74bfbad45 --- /dev/null +++ b/.changeset/modern-doors-watch.md @@ -0,0 +1,5 @@ +--- +'rrweb-snapshot': patch +--- + +better nested css selector splitting when commas or brackets happen to be in quoted text diff --git a/packages/rrweb-snapshot/src/css.ts b/packages/rrweb-snapshot/src/css.ts index d7a413eb67..e093660bbb 100644 --- a/packages/rrweb-snapshot/src/css.ts +++ b/packages/rrweb-snapshot/src/css.ts @@ -435,17 +435,67 @@ export function parse(css: string, options: ParserOptions = {}) { if (!m) { return; } + /* @fix Remove all comments from selectors * http://ostermiller.org/findcomment.html */ - return trim(m[0]) + 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(/\s*(?![^(]*\)),\s*/) - .map((s) => { - return s.replace(/\u200C/g, ','); }); + + // 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; } /** diff --git a/packages/rrweb-snapshot/test/css.test.ts b/packages/rrweb-snapshot/test/css.test.ts index 2818386071..6b95c2b151 100644 --- a/packages/rrweb-snapshot/test/css.test.ts +++ b/packages/rrweb-snapshot/test/css.test.ts @@ -119,6 +119,129 @@ describe('css parser', () => { expect(out3).toEqual('[data-aa\\:other] { color: red; }'); }); + 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); + }); + + it.each([ + ['.foo,.bar {}', ['.foo', '.bar']], + ['.bar:has(:disabled) {}', ['.bar:has(:disabled)']], + ['.bar:has(input, button) {}', ['.bar:has(input, button)']], + [ + '.bar:has(input:is(:disabled),button:has(:disabled)) {}', + ['.bar:has(input:is(:disabled),button:has(:disabled))'], + ], + [ + '.bar:has(div, input:is(:disabled), button) {}', + ['.bar:has(div, input:is(:disabled), button)'], + ], + [ + '.bar:has(div, input:is(:disabled),button:has(:disabled,.baz)) {}', + ['.bar:has(div, input:is(:disabled),button:has(:disabled,.baz))'], + ], + [ + '.bar:has(input), .foo:has(input, button), .baz {}', + ['.bar:has(input)', '.foo:has(input, button)', '.baz'], + ], + [ + '.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}', + [ + '.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz))', + ], + ], + [ + '.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button) {}', + [ + '.bar:has(:has(:has(a), :has(:has(:has(b, :has(a), c), e))), input:is(:disabled), button)', + ], + ], + [ + '.foo,.bar:has(input:is(:disabled)){color: red;}', + ['.foo', '.bar:has(input:is(:disabled))'], + ], + [ + '.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz)){color: red;}', + ['.foo', '.bar:has(input:is(:disabled),button:has(:disabled,.baz))'], + ], + [ + '.foo,.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz)){color: red;}', + [ + '.foo', + '.bar:has(input:is(:disabled),button:has(:disabled), div:has(:disabled,.baz))', + ], + ], + [ + '.foo,.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz)){color: red;}', + [ + '.foo', + '.bar:has(input:is(:disabled),button:has(:disabled,.baz), div:has(:disabled,.baz))', + ], + ], + ['.bar:has(:disabled), .foo {}', ['.bar:has(:disabled)', '.foo']], + [ + '.bar:has(input:is(:disabled),.foo,button:is(:disabled)), .foo {}', + ['.bar:has(input:is(:disabled),.foo,button:is(:disabled))', '.foo'], + ], + [ + '.bar:has(input:is(:disabled),.foo,button:is(:disabled)), .foo:has(input, button), .baz, {}', + [ + '.bar:has(input:is(:disabled),.foo,button:is(:disabled))', + '.foo:has(input, button)', + '.baz', + ], + ], + ])( + 'can parse selector(s) with functional pseudo classes: %s', + (cssText, expected) => { + expect( + parse( + cssText, + // @ts-ignore + ).stylesheet?.rules[0].selectors, + ).toEqual(expected); + }, + ); + it('parses imports with quotes correctly', () => { const out1 = escapeImportStatement({ cssText: `@import url("/foo.css;900;800"");`,