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:
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>"
|
||||||
|
|||||||
@@ -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;`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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; }');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user