Skip to content

Commit

Permalink
better splitting of selectors (rrweb-io#1440)
Browse files Browse the repository at this point in the history
* better splitting of selectors - overlapping with rrweb-io#1401
* Add test from example at PostHog/posthog#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 <[email protected]>
  • Loading branch information
2 people authored and billyvg committed Apr 19, 2024
1 parent 928280e commit 8053361
Show file tree
Hide file tree
Showing 3 changed files with 183 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/modern-doors-watch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'rrweb-snapshot': patch
---

better nested css selector splitting when commas or brackets happen to be in quoted text
60 changes: 55 additions & 5 deletions packages/rrweb-snapshot/src/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
123 changes: 123 additions & 0 deletions packages/rrweb-snapshot/test/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"");`,
Expand Down

0 comments on commit 8053361

Please sign in to comment.