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