feat: add new css parser - postcss (#1458)

* feat: add new css parser

* make selectors change

* selectors and tests

* media changes

* remove old css references

* better variable name

* use postcss and port tests

* fix media test

* inline plugins

* fix failing multiline selector

* correct test result

* move tests to correct file

* cleanup all tests

* remove unused css-tree

* update bundle

* cleanup dependencies

* revert config files to master

* remove d.ts files

* update snapshot

* reset rebuilt test

* apply fuzzy css matching

* remove extra test

* Fix imports

* Newer versions of nswapi break rrdom-nodejs tests.
Example:
 FAIL  test/document-nodejs.test.ts > RRDocument for nodejs environment > RRDocument API > querySelectorAll
TypeError: e[api] is not a function
 ❯ byTag ../../node_modules/nwsapi/src/nwsapi.js:390:37
 ❯ Array.<anonymous> ../../node_modules/nwsapi/src/nwsapi.js:327:113
 ❯ collect ../../node_modules/nwsapi/src/nwsapi.js:1578:32
 ❯ Object._querySelectorAll [as select] ../../node_modules/nwsapi/src/nwsapi.js:1533:36
 ❯ RRDocument.querySelectorAll src/document-nodejs.ts:96:24

* Migrate from jest to vitest

* Order of selectors has changed with postcss

* Remove unused eslint

---------

Co-authored-by: Justin Halsall <Juice10@users.noreply.github.com>
This commit is contained in:
David Newell
2026-04-01 12:00:00 +08:00
committed by GitHub
parent a0b3d054b7
commit 83e8dd8ab7
9 changed files with 3742 additions and 5764 deletions

View File

@@ -54,7 +54,7 @@
"dependencies": { "dependencies": {
"cssom": "^0.5.0", "cssom": "^0.5.0",
"cssstyle": "^2.3.0", "cssstyle": "^2.3.0",
"nwsapi": "^2.2.0", "nwsapi": "2.2.0",
"rrdom": "^2.0.0-alpha.16", "rrdom": "^2.0.0-alpha.16",
"rrweb-snapshot": "^2.0.0-alpha.16" "rrweb-snapshot": "^2.0.0-alpha.16"
} }

View File

@@ -63,5 +63,8 @@
"vite": "^5.3.1", "vite": "^5.3.1",
"vite-plugin-dts": "^3.9.1", "vite-plugin-dts": "^3.9.1",
"vitest": "^1.4.0" "vitest": "^1.4.0"
},
"dependencies": {
"postcss": "^8.4.38"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { type Rule, type Media, type NodeWithRules, parse } from './css'; import { mediaSelectorPlugin, pseudoClassPlugin } from './css';
import { import {
type serializedNodeWithId, type serializedNodeWithId,
NodeType, NodeType,
@@ -8,6 +8,7 @@ import {
type legacyAttributes, type legacyAttributes,
} from './types'; } from './types';
import { isElement, Mirror, isNodeMetaEqual } from './utils'; import { isElement, Mirror, isNodeMetaEqual } from './utils';
import postcss from 'postcss';
const tagMap: tagMap = { const tagMap: tagMap = {
script: 'noscript', script: 'noscript',
@@ -57,83 +58,15 @@ function getTagName(n: elementNode): string {
return tagName; 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 { export function adaptCssForReplay(cssText: string, cache: BuildCache): string {
const cachedStyle = cache?.stylesWithHoverClass.get(cssText); const cachedStyle = cache?.stylesWithHoverClass.get(cssText);
if (cachedStyle) return cachedStyle; if (cachedStyle) return cachedStyle;
const ast = parse(cssText, { const ast: { css: string } = postcss([
silent: true, mediaSelectorPlugin,
}); pseudoClassPlugin,
]).process(cssText);
if (!ast.stylesheet) { const result = ast.css;
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 = cssText;
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 (medias.length > 0) {
const mediaMatcher = new RegExp(
medias
.filter((media, index) => medias.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); cache?.stylesWithHoverClass.set(cssText, result);
return result; return result;
} }

View File

@@ -269,7 +269,9 @@ exports[`integration tests > [html file]: hover.html 1`] = `
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" /> <meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\" />
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" /> <meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\" />
<title>hover selector</title> <title>hover selector</title>
<style>div:hover, div.\\\\:hover { background: orange; }div:hover::after, div.\\\\:hover::after { position: absolute; left: 0px; top: 100%; content: \\"dropdown\\"; width: 100px; height: 200px; background: lightblue; }</style> <style>div:hover,
div.\\\\:hover { background: orange; }div:hover::after,
div.\\\\:hover::after { position: absolute; left: 0px; top: 100%; content: \\"dropdown\\"; width: 100px; height: 200px; background: lightblue; }</style>
</head><body> </head><body>
<div>hover me</div> <div>hover me</div>
</body></html>" </body></html>"

View File

@@ -1,255 +1,75 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { parse, Rule, Media } from '../src/css'; import { mediaSelectorPlugin, pseudoClassPlugin } from '../src/css';
import { fixSafariColons, escapeImportStatement } from './../src/utils'; import postcss, { AcceptedPlugin } from 'postcss';
describe('css parser', () => { describe('css parser', () => {
it('should save the filename and source', () => { function parse(plugin: AcceptedPlugin, input: string): string {
const css = 'booty {\n size: large;\n}\n'; const ast = postcss([plugin]).process(input, {});
const ast = parse(css, { return ast.css;
source: 'booty.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);
}); });
expect(ast.stylesheet!.source).toEqual('booty.css'); it('can adapt media rules to replay context', () => {
[
const position = ast.stylesheet!.rules[0].position!; ['min', 'width'],
expect(position.start).toBeTruthy(); ['min', 'height'],
expect(position.end).toBeTruthy(); ['max', 'width'],
expect(position.source).toEqual('booty.css'); ['max', 'height'],
expect(position.content).toEqual(css); ].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; }}`,
);
});
});
}); });
it('should throw when a selector is missing', () => { describe('pseudoClassPlugin', () => {
expect(() => { it('parses nested commas in selectors correctly', () => {
parse('{size: large}'); const cssText =
}).toThrow(); '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);
});
expect(() => { it('should parse selector with comma nested inside ()', () => {
parse('b { color: red; }\n{ color: green; }\na { color: blue; }'); const cssText =
}).toThrow(); '[_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('should throw when a broken comment is found', () => { it('ignores ( in strings', () => {
expect(() => { const cssText =
parse('thing { color: red; } /* b { color: blue; }'); 'li[attr="weirdly("] a:hover, li[attr="weirdly)"] a {background-color: red;}';
}).toThrow(); expect(parse(pseudoClassPlugin, cssText))
.toEqual(`li[attr="weirdly("] a:hover, li[attr="weirdly)"] a,
li[attr="weirdly("] a.\\:hover {background-color: red;}`);
});
expect(() => { it('ignores escaping in strings', () => {
parse('/*'); const cssText = `li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a {background-color: red;}`;
}).toThrow(); expect(parse(pseudoClassPlugin, cssText))
.toEqual(`li[attr="weirder\\"("] a:hover, li[attr="weirder\\")"] a,
li[attr="weirder\\"("] a.\\:hover {background-color: red;}`);
});
/* Nested comments should be fine */ it('ignores comma in string', () => {
expect(() => { const cssText = 'li[attr="has,comma"] a:hover {background: red;}';
parse('/* /* */'); expect(parse(pseudoClassPlugin, cssText)).toEqual(
}).not.toThrow(); `li[attr="has,comma"] a:hover,
}); li[attr="has,comma"] a.\\:hover {background: red;}`,
);
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(
`
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('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;`);
}); });
}); });

View File

@@ -3,7 +3,7 @@
*/ */
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { describe, it, beforeEach, expect } from 'vitest'; import { describe, it, beforeEach, expect as _expect } from 'vitest';
import { import {
adaptCssForReplay, adaptCssForReplay,
buildNodeWithSN, buildNodeWithSN,
@@ -12,6 +12,30 @@ import {
import { NodeType } from '../src/types'; import { NodeType } from '../src/types';
import { createMirror, Mirror } from '../src/utils'; import { createMirror, Mirror } from '../src/utils';
const expect = _expect as unknown as {
<T = unknown>(actual: T): {
toMatchCss(expected: string): void;
} & ReturnType<typeof _expect>;
} & typeof _expect;
expect.extend({
toMatchCss: function (received: string, expected: string) {
const pass = normCss(received) === normCss(expected);
const message: () => string = () =>
pass
? ''
: `Received (${received}) is not the same as expected (${expected})`;
return {
message,
pass,
};
},
});
function normCss(cssText: string): string {
return cssText.replace(/[\s;]/g, '');
}
function getDuration(hrtime: [number, number]) { function getDuration(hrtime: [number, number]) {
const [seconds, nanoseconds] = hrtime; const [seconds, nanoseconds] = hrtime;
return seconds * 1000 + nanoseconds / 1000000; return seconds * 1000 + nanoseconds / 1000000;
@@ -86,19 +110,19 @@ describe('rebuild', function () {
describe('add hover class to hover selector related rules', function () { describe('add hover class to hover selector related rules', function () {
it('will do nothing to css text without :hover', () => { 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); expect(adaptCssForReplay(cssText, cache)).toMatchCss(cssText);
}); });
it('can add hover class to css text', () => { it('can add hover class to css text', () => {
const cssText = '.a:hover { color: white }'; const cssText = '.a:hover { color: white }';
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'.a:hover, .a.\\:hover { color: white }', '.a:hover, .a.\\:hover { color: white }',
); );
}); });
it('can correctly add hover when in middle of selector', () => { it('can correctly add hover when in middle of selector', () => {
const cssText = 'ul li a:hover img { color: white }'; const cssText = 'ul li a:hover img { color: white }';
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'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,14 +135,15 @@ img,
ul li.specified c:hover img { ul li.specified c:hover img {
color: white color: white
}`; }`;
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
`ul li.specified a:hover img, ul li.specified a.\\:hover img, `ul li.specified a:hover img,
ul li.multiline ul li.multiline
b:hover b:hover
img, ul li.multiline
b.\\:hover
img, img,
ul li.specified c:hover img, ul li.specified c.\\: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 color: white
}`, }`,
); );
@@ -126,48 +151,48 @@ ul li.specified c:hover img, ul li.specified c.\\:hover img {
it('can add hover class within media query', () => { it('can add hover class within media query', () => {
const cssText = '@media screen { .m:hover { color: white } }'; const cssText = '@media screen { .m:hover { color: white } }';
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'@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', () => { it('can add hover class when there is multi selector', () => {
const cssText = '.a, .b:hover, .c { color: white }'; const cssText = '.a, .b:hover, .c { color: white }';
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'.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', () => { it('can add hover class when there is a multi selector with the same prefix', () => {
const cssText = '.a:hover, .a:hover::after { color: white }'; const cssText = '.a:hover, .a:hover::after { color: white }';
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'.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', () => { it('can add hover class when :hover is not the end of selector', () => {
const cssText = 'div:hover::after { color: white }'; const cssText = 'div:hover::after { color: white }';
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'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', () => { it('can add hover class when the selector has multi :hover', () => {
const cssText = 'a:hover b:hover { color: white }'; const cssText = 'a:hover b:hover { color: white }';
expect(adaptCssForReplay(cssText, cache)).toEqual( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'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', () => { it('will ignore :hover in css value', () => {
const cssText = '.a::after { content: ":hover" }'; const cssText = '.a::after { content: ":hover" }';
expect(adaptCssForReplay(cssText, cache)).toEqual(cssText); expect(adaptCssForReplay(cssText, cache)).toMatchCss(cssText);
}); });
it('can adapt media rules to replay context', () => { it('can adapt media rules to replay context', () => {
const cssText = 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( expect(adaptCssForReplay(cssText, cache)).toMatchCss(
'@media only screen and (min-width : 1200px) { .a { width: 10px; }}', '@media only screen and (min-width : 1200px) { .a { width: 10px; }}',
); );
}); });
@@ -210,7 +235,7 @@ ul li.specified c:hover img, ul li.specified c.\\:hover img {
// previously that part was being incorrectly consumed by the selector regex // previously that part was being incorrectly consumed by the selector regex
const should_not_modify = const should_not_modify =
".tailwind :is(.before\\:content-\\[\\'\\'\\])::before { --tw-content: \":hover\"; content: var(--tw-content); }.tailwind :is(.\\[\\&\\>li\\]\\:before\\:content-\\[\\'-\\'\\] > li)::before { color: pink; }"; ".tailwind :is(.before\\:content-\\[\\'\\'\\])::before { --tw-content: \":hover\"; content: var(--tw-content); }.tailwind :is(.\\[\\&\\>li\\]\\:before\\:content-\\[\\'-\\'\\] > li)::before { color: pink; }";
expect(adaptCssForReplay(should_not_modify, cache)).toEqual( expect(adaptCssForReplay(should_not_modify, cache)).toMatchCss(
should_not_modify, should_not_modify,
); );
}); });
@@ -219,7 +244,7 @@ ul li.specified c:hover img, ul li.specified c.\\:hover img {
// the ':hover' in the below is a decoy which is not part of the selector, // the ':hover' in the below is a decoy which is not part of the selector,
const should_not_modify = const should_not_modify =
'@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;1,400&display=:hover");'; '@import url("https://fonts.googleapis.com/css2?family=Rubik:ital,wght@0,400;0,500;0,700;1,400&display=:hover");';
expect(adaptCssForReplay(should_not_modify, cache)).toEqual( expect(adaptCssForReplay(should_not_modify, cache)).toMatchCss(
should_not_modify, should_not_modify,
); );
}); });

View File

@@ -3,7 +3,12 @@
*/ */
import { describe, it, test, expect } from 'vitest'; import { describe, it, test, expect } from 'vitest';
import { NodeType, serializedNode } from '../src/types'; import { NodeType, serializedNode } from '../src/types';
import { extractFileExtension, isNodeMetaEqual } from '../src/utils'; import {
escapeImportStatement,
extractFileExtension,
fixSafariColons,
isNodeMetaEqual,
} from '../src/utils';
import type { serializedNodeWithId } from 'rrweb-snapshot'; import type { serializedNodeWithId } from 'rrweb-snapshot';
describe('utils', () => { describe('utils', () => {
@@ -199,4 +204,80 @@ describe('utils', () => {
expect(extension).toBe('js'); 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;`);
});
});
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; }');
});
});
}); });

7905
yarn.lock

File diff suppressed because it is too large Load Diff