feat(core): css-what parser for CSS selectors + support for :not(), :is(), and :where() Level 4 and ~ (#10514)

This commit is contained in:
Dimitris-Rafail Katsampas
2024-06-28 23:57:29 +03:00
committed by GitHub
parent 88a047254b
commit 2fb4f23670
10 changed files with 750 additions and 449 deletions

View File

@@ -278,6 +278,111 @@ export function test_id_selector() {
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
export function test_not_pseudo_class_selector() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-not-pseudo-class-selector
page.css = 'Button:not(#myButton) { color: red; }';
//// Will be styled
btnWithNoId = new Button();
// << article-using-not-pseudo-class-selector
//// Won't be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithNoId);
stack.addChild(btnWithId);
helper.assertViewColor(btnWithNoId, '#FF0000');
TKUnit.assert(btnWithId.style.color === undefined, 'Color should not have a value');
}
export function test_is_pseudo_class_selector() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-is-pseudo-class-selector
page.css = 'Button:is(#myButton) { color: red; }';
//// Will be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
//// Won't be styled
btnWithNoId = new Button();
// << article-using-is-pseudo-class-selector
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithId);
stack.addChild(btnWithNoId);
helper.assertViewColor(btnWithId, '#FF0000');
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
export function test_where_pseudo_class_selector() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-where-pseudo-class-selector
page.css = 'Button:where(#myButton) { color: red; }';
//// Will be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
//// Won't be styled
btnWithNoId = new Button();
// << article-using-where-pseudo-class-selector
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithId);
stack.addChild(btnWithNoId);
helper.assertViewColor(btnWithId, '#FF0000');
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
export function test_where_pseudo_class_selector_zero_specificity() {
let page = helper.getClearCurrentPage();
page.style.color = unsetValue;
let btnWithId: Button;
let btnWithNoId: Button;
// >> article-using-where-pseudo-class-selector-zero-specificity
page.css = '#myButton { color: green; } Button:where(#myButton) { color: red; }';
//// Will be styled
btnWithId = new Button();
btnWithId.id = 'myButton';
//// Won't be styled
btnWithNoId = new Button();
// << article-using-where-pseudo-class-selector-zero-specificity
const stack = new StackLayout();
page.content = stack;
stack.addChild(btnWithId);
stack.addChild(btnWithNoId);
// Pseudo-class :where() has zero specificity, therefore we expect the first rule to be applied
helper.assertViewColor(btnWithId, '#008000');
TKUnit.assert(btnWithNoId.style.color === undefined, 'Color should not have a value');
}
// State selector tests
export function test_state_selector() {
let page = helper.getClearCurrentPage();
@@ -763,7 +868,7 @@ export function test_set_invalid_CSS_values_dont_cause_crash() {
(views: Array<View>) => {
TKUnit.assertEqual(30, testButton.style.fontSize);
},
{ pageCss: invalidCSS }
{ pageCss: invalidCSS },
);
}
@@ -782,7 +887,7 @@ export function test_set_mixed_CSS_cases_works() {
helper.assertViewBackgroundColor(testButton, '#FF0000');
helper.assertViewColor(testButton, '#0000FF');
},
{ pageCss: casedCSS }
{ pageCss: casedCSS },
);
}

View File

@@ -31,6 +31,29 @@
color: white;
}
.general-sibling--type Button ~ Label {
background-color: green;
color: white;
}
.general-sibling--class .test-child ~ .test-child-2 {
background-color: yellow;
}
.general-sibling--attribute Button[data="test-child"] ~ Button[data="test-child-2"] {
background-color: blueviolet;
color: white;
}
.general-sibling--pseudo-selector Button.ref ~ Button:disabled {
background-color: black;
color: white;
}
.sibling-test-label {
text-align: center;
}
.sibling-test-label {
margin-top: 8;
}

View File

@@ -61,6 +61,39 @@
<Button isEnabled="false" text="But I am!"/>
</StackLayout>
<StackLayout class="general-sibling--type">
<Label text="General sibling test by type"/>
<Label class="sibling-test-label" text="I'm not!"/>
<Button text="I'm the ref"/>
<Label class="sibling-test-label" text="I'm a general sibling!"/>
<Label class="sibling-test-label" text="Me too!"/>
</StackLayout>
<StackLayout class="general-sibling--class">
<Label text="General sibling test by class"/>
<Button class="test-child-2" text="I'm not!"/>
<Button class="test-child" text="I'm the ref"/>
<Button class="test-child-2" text="I'm a general sibling!"/>
<Button class="test-child-2" text="Me too!"/>
</StackLayout>
<StackLayout class="general-sibling--attribute">
<Label text="General sibling test by attribute"/>
<Button data="test-child-2" text="I'm not!"/>
<Button data="test-child" text="I'm the ref"/>
<Button data="test-child-2" text="I'm a general sibling!"/>
<Button data="test-child-2" text="Me too!"/>
</StackLayout>
<StackLayout class="general-sibling--pseudo-selector">
<Label text="General sibling test by pseudo-selector"/>
<Button text="I'm not!"/>
<Button isEnabled="false" text="I'm not either!"/>
<Button class="ref" text="I'm the ref"/>
<Button isEnabled="false" text="I'm a general sibling!"/>
<Button isEnabled="false" text="Me too!"/>
</StackLayout>
</StackLayout>
</ScrollView>
</Page>

View File

@@ -42,6 +42,7 @@
"copyfiles": "^2.4.0",
"css": "^3.0.0",
"css-tree": "^1.1.2",
"css-what": "^6.1.0",
"dotenv": "~16.4.0",
"emoji-regex": "^10.3.0",
"eslint": "~8.57.0",
@@ -76,4 +77,3 @@
]
}
}

View File

@@ -1,5 +1,5 @@
import { Color } from '../color';
import { parseURL, parseColor, parsePercentageOrLength, parseBackgroundPosition, parseBackground, parseSelector, AttributeSelectorTest } from './parser';
import { parseURL, parseColor, parsePercentageOrLength, parseBackgroundPosition, parseBackground } from './parser';
import { CSS3Parser, TokenObjectType } from './CSS3Parser';
import { CSSNativeScript } from './CSSNativeScript';
@@ -155,121 +155,6 @@ describe('css', () => {
});
});
describe('selectors', () => {
test(parseSelector, ` listview#products.mark gridlayout:selected[row="2"] a> b > c >d>e *[src] `, {
start: 0,
end: 79,
value: [
[
[
{ type: '', identifier: 'listview' },
{ type: '#', identifier: 'products' },
{ type: '.', identifier: 'mark' },
],
' ',
],
[
[
{ type: '', identifier: 'gridlayout' },
{ type: ':', identifier: 'selected' },
{ type: '[]', property: 'row', test: '=', value: '2' },
],
' ',
],
[[{ type: '', identifier: 'a' }], '>'],
[[{ type: '', identifier: 'b' }], '>'],
[[{ type: '', identifier: 'c' }], '>'],
[[{ type: '', identifier: 'd' }], '>'],
[[{ type: '', identifier: 'e' }], ' '],
[[{ type: '*' }, { type: '[]', property: 'src' }], undefined],
],
});
test(parseSelector, '*', { start: 0, end: 1, value: [[[{ type: '*' }], undefined]] });
test(parseSelector, 'button', { start: 0, end: 6, value: [[[{ type: '', identifier: 'button' }], undefined]] });
test(parseSelector, '.login', { start: 0, end: 6, value: [[[{ type: '.', identifier: 'login' }], undefined]] });
test(parseSelector, '#login', { start: 0, end: 6, value: [[[{ type: '#', identifier: 'login' }], undefined]] });
test(parseSelector, ':hover', { start: 0, end: 6, value: [[[{ type: ':', identifier: 'hover' }], undefined]] });
test(parseSelector, '[src]', { start: 0, end: 5, value: [[[{ type: '[]', property: 'src' }], undefined]] });
test(parseSelector, `[src = "res://"]`, { start: 0, end: 16, value: [[[{ type: '[]', property: 'src', test: '=', value: `res://` }], undefined]] });
(<AttributeSelectorTest[]>['=', '^=', '$=', '*=', '=', '~=', '|=']).forEach((attributeTest) => {
test(parseSelector, `[src ${attributeTest} "val"]`, { start: 0, end: 12 + attributeTest.length, value: [[[{ type: '[]', property: 'src', test: attributeTest, value: 'val' }], undefined]] });
});
test(parseSelector, 'listview > .image', {
start: 0,
end: 17,
value: [
[[{ type: '', identifier: 'listview' }], '>'],
[[{ type: '.', identifier: 'image' }], undefined],
],
});
test(parseSelector, 'listview .image', {
start: 0,
end: 16,
value: [
[[{ type: '', identifier: 'listview' }], ' '],
[[{ type: '.', identifier: 'image' }], undefined],
],
});
test(parseSelector, 'button:hover', {
start: 0,
end: 12,
value: [
[
[
{ type: '', identifier: 'button' },
{ type: ':', identifier: 'hover' },
],
undefined,
],
],
});
test(parseSelector, 'listview>:selected image.product', {
start: 0,
end: 32,
value: [
[[{ type: '', identifier: 'listview' }], '>'],
[[{ type: ':', identifier: 'selected' }], ' '],
[
[
{ type: '', identifier: 'image' },
{ type: '.', identifier: 'product' },
],
undefined,
],
],
});
test(parseSelector, 'button[testAttr]', {
start: 0,
end: 16,
value: [
[
[
{ type: '', identifier: 'button' },
{ type: '[]', property: 'testAttr' },
],
undefined,
],
],
});
test(parseSelector, 'button#login[user][pass]:focused:hovered', {
start: 0,
end: 40,
value: [
[
[
{ type: '', identifier: 'button' },
{ type: '#', identifier: 'login' },
{ type: '[]', property: 'user' },
{ type: '[]', property: 'pass' },
{ type: ':', identifier: 'focused' },
{ type: ':', identifier: 'hovered' },
],
undefined,
],
],
});
});
describe('css3', () => {
let themeCoreLightIos: string;
let whatIsNewIos: string;
@@ -468,7 +353,7 @@ describe('css', () => {
const reworkAst = reworkCss.parse(themeCoreLightIos, { source: 'nativescript-theme-core/css/core.light.css' });
fs.writeFileSync(
outReworkFile,
JSON.stringify(reworkAst, (k, v) => (k === 'position' ? undefined : v), ' ')
JSON.stringify(reworkAst, (k, v) => (k === 'position' ? undefined : v), ' '),
);
const nsParser = new CSS3Parser(themeCoreLightIos);

View File

@@ -612,161 +612,3 @@ export function parseBackground(text: string, start = 0): Parsed<Background> {
return { start, end, value };
}
// Selectors
export type Combinator = '+' | '~' | '>' | ' ';
export interface UniversalSelector {
type: '*';
}
export interface TypeSelector {
type: '';
identifier: string;
}
export interface ClassSelector {
type: '.';
identifier: string;
}
export interface IdSelector {
type: '#';
identifier: string;
}
export interface PseudoClassSelector {
type: ':';
identifier: string;
}
export type AttributeSelectorTest = '=' | '^=' | '$=' | '*=' | '~=' | '|=';
export interface AttributeSelector {
type: '[]';
property: string;
test?: AttributeSelectorTest;
value?: string;
}
export type SimpleSelector = UniversalSelector | TypeSelector | ClassSelector | IdSelector | PseudoClassSelector | AttributeSelector;
export type SimpleSelectorSequence = SimpleSelector[];
export type SelectorCombinatorPair = [SimpleSelectorSequence, Combinator];
export type Selector = SelectorCombinatorPair[];
const universalSelectorRegEx = /\*/gy;
export function parseUniversalSelector(text: string, start = 0): Parsed<UniversalSelector> {
universalSelectorRegEx.lastIndex = start;
const result = universalSelectorRegEx.exec(text);
if (!result) {
return null;
}
const end = universalSelectorRegEx.lastIndex;
return { start, end, value: { type: '*' } };
}
const simpleIdentifierSelectorRegEx = /(#|\.|:|\b)((?:[\w_-]|\\.)(?:[\w\d_-]|\\.)*)/guy;
const unicodeEscapeRegEx = /\\([0-9a-fA-F]{1,5}\s|[0-9a-fA-F]{6})/g;
export function parseSimpleIdentifierSelector(text: string, start = 0): Parsed<TypeSelector | ClassSelector | IdSelector | PseudoClassSelector> {
simpleIdentifierSelectorRegEx.lastIndex = start;
const result = simpleIdentifierSelectorRegEx.exec(text.replace(unicodeEscapeRegEx, (_, c) => '\\' + String.fromCodePoint(parseInt(c.trim(), 16))));
if (!result) {
return null;
}
const end = simpleIdentifierSelectorRegEx.lastIndex;
const type = <'#' | '.' | ':' | ''>result[1];
const identifier: string = result[2].replace(/\\/g, '');
const value = <TypeSelector | ClassSelector | IdSelector | PseudoClassSelector>{ type, identifier };
return { start, end, value };
}
const attributeSelectorRegEx = /\[\s*([_\-\w][_\-\w\d]*)\s*(?:(=|\^=|\$=|\*=|\~=|\|=)\s*(?:([_\-\w][_\-\w\d]*)|"((?:[^\\"]|\\(?:"|n|r|f|\\|0-9a-f))*)"|'((?:[^\\']|\\(?:'|n|r|f|\\|0-9a-f))*)')\s*)?\]/gy;
export function parseAttributeSelector(text: string, start: number): Parsed<AttributeSelector> {
attributeSelectorRegEx.lastIndex = start;
const result = attributeSelectorRegEx.exec(text);
if (!result) {
return null;
}
const end = attributeSelectorRegEx.lastIndex;
const property = result[1];
if (result[2]) {
const test = <AttributeSelectorTest>result[2];
const value = result[3] || result[4] || result[5];
return { start, end, value: { type: '[]', property, test, value } };
}
return { start, end, value: { type: '[]', property } };
}
export function parseSimpleSelector(text: string, start = 0): Parsed<SimpleSelector> {
return parseUniversalSelector(text, start) || parseSimpleIdentifierSelector(text, start) || parseAttributeSelector(text, start);
}
export function parseSimpleSelectorSequence(text: string, start: number): Parsed<SimpleSelector[]> {
let simpleSelector = parseSimpleSelector(text, start);
if (!simpleSelector) {
return null;
}
let end = simpleSelector.end;
const value = <SimpleSelectorSequence>[];
while (simpleSelector) {
value.push(simpleSelector.value);
end = simpleSelector.end;
simpleSelector = parseSimpleSelector(text, end);
}
return { start, end, value };
}
const combinatorRegEx = /\s*([+~>])?\s*/gy;
export function parseCombinator(text: string, start = 0): Parsed<Combinator> {
combinatorRegEx.lastIndex = start;
const result = combinatorRegEx.exec(text);
if (!result) {
return null;
}
const end = combinatorRegEx.lastIndex;
const value = <Combinator>result[1] || ' ';
return { start, end, value };
}
const whiteSpaceRegEx = /\s*/gy;
export function parseSelector(text: string, start = 0): Parsed<Selector> {
let end = start;
whiteSpaceRegEx.lastIndex = end;
const leadingWhiteSpace = whiteSpaceRegEx.exec(text);
if (leadingWhiteSpace) {
end = whiteSpaceRegEx.lastIndex;
}
const value = <Selector>[];
let combinator: Parsed<Combinator>;
let expectSimpleSelector = true; // Must have at least one
let pair: SelectorCombinatorPair;
do {
const simpleSelectorSequence = parseSimpleSelectorSequence(text, end);
if (!simpleSelectorSequence) {
if (expectSimpleSelector) {
return null;
} else {
break;
}
}
end = simpleSelectorSequence.end;
if (combinator) {
// This logic looks weird; this `if` statement would occur on the next LOOP, so it effects the prior `pair`
// variable which is already pushed into the `value` array is going to have its `undefined` set to this
// value before the following statement creates a new `pair` memory variable.
// noinspection JSUnusedAssignment
pair[1] = combinator.value;
}
pair = [simpleSelectorSequence.value, undefined];
value.push(pair);
combinator = parseCombinator(text, end);
if (combinator) {
end = combinator.end;
}
expectSimpleSelector = combinator && combinator.value !== ' '; // Simple selector must follow non trailing white space combinator
} while (combinator);
return { start, end, value };
}

View File

@@ -57,6 +57,7 @@
"@nativescript/hook": "~2.0.0",
"acorn": "^8.7.0",
"css-tree": "^1.1.2",
"css-what": "^6.1.0",
"emoji-regex": "^10.2.1",
"reduce-css-calc": "^2.1.7",
"tslib": "^2.0.0"

View File

@@ -8,12 +8,12 @@ describe('css-selector', () => {
sel.match(<any>{
cssType: 'button',
testAttr: true,
})
}),
).toBeTruthy();
expect(
sel.match(<any>{
cssType: 'button',
})
}),
).toBeFalsy();
});
@@ -119,7 +119,7 @@ describe('css-selector', () => {
}
});
it('direct parent combinator', () => {
it('direct child combinator', () => {
const rule = createOne(`listview > item:selected { color: red; }`);
expect(
rule.selectors[0].match({
@@ -128,7 +128,7 @@ describe('css-selector', () => {
parent: {
cssType: 'listview',
},
})
}),
).toBe(true);
expect(
rule.selectors[0].match({
@@ -140,11 +140,11 @@ describe('css-selector', () => {
cssType: 'listview',
},
},
})
}),
).toBe(false);
});
it('ancestor combinator', () => {
it('descendant combinator', () => {
const rule = createOne(`listview item:selected { color: red; }`);
expect(
rule.selectors[0].match({
@@ -153,7 +153,7 @@ describe('css-selector', () => {
parent: {
cssType: 'listview',
},
})
}),
).toBe(true);
expect(
rule.selectors[0].match({
@@ -165,7 +165,7 @@ describe('css-selector', () => {
cssType: 'listview',
},
},
})
}),
).toBe(true);
expect(
rule.selectors[0].match({
@@ -177,7 +177,7 @@ describe('css-selector', () => {
cssType: 'page',
},
},
})
}),
).toBe(false);
});
@@ -202,6 +202,79 @@ describe('css-selector', () => {
expect(sel.match(child)).toBe(true);
});
it(':not() pseudo-class', () => {
const rule = createOne(`listview :not(item:selected) { color: red; }`);
expect(
rule.selectors[0].match({
cssType: 'item',
cssPseudoClasses: new Set(['selected']),
parent: {
cssType: 'listview',
},
}),
).toBe(false);
expect(
rule.selectors[0].match({
cssType: 'item',
parent: {
cssType: 'listview',
},
}),
).toBe(true);
expect(
rule.selectors[0].match({
cssType: 'label',
parent: {
cssType: 'listview',
},
}),
).toBe(true);
});
it(':is() pseudo-class', () => {
const rule = createOne(`listview :is(item:selected) { color: red; }`);
expect(
rule.selectors[0].match({
cssType: 'item',
cssPseudoClasses: new Set(['selected']),
parent: {
cssType: 'listview',
},
}),
).toBe(true);
expect(
rule.selectors[0].match({
cssType: 'item',
parent: {
cssType: 'listview',
},
}),
).toBe(false);
});
it(':where() pseudo-class', () => {
const rule = createOne(`listview :is(item:selected) { color: red; }`);
expect(
rule.selectors[0].match({
cssType: 'item',
cssPseudoClasses: new Set(['selected']),
parent: {
cssType: 'listview',
},
}),
).toBe(true);
expect(
rule.selectors[0].match({
cssType: 'item',
parent: {
cssType: 'listview',
},
}),
).toBe(false);
// TODO: Re-add this when decorators actually work with ts-jest
//expect(rule.selectors[0].specificity).toEqual(0);
});
function toString() {
return this.cssType;
}

View File

@@ -1,9 +1,10 @@
import { parse as convertToCSSWhatSelector, Selector as CSSWhatSelector, DataType as CSSWhatDataType } from 'css-what';
import '../../globals';
import { isCssVariable } from '../core/properties';
import { Trace } from '../../trace';
import { isNullOrUndefined } from '../../utils/types';
import * as ReworkCSS from '../../css';
import { Combinator as ICombinator, SimpleSelectorSequence as ISimpleSelectorSequence, Selector as ISelector, SimpleSelector as ISimpleSelector, parseSelector } from '../../css/parser';
/**
* An interface describing the shape of a type on which the selectors may apply.
@@ -20,6 +21,7 @@ export interface Node {
cssPseudoClasses?: Set<string>;
getChildIndex?(node: Node): number;
getChildAt?(index: number): Node;
getChildrenCount?(): number;
}
export interface Declaration {
@@ -34,6 +36,7 @@ export interface Changes {
pseudoClasses?: Set<string>;
}
/* eslint-disable @typescript-eslint/no-duplicate-enum-values */
const enum Specificity {
Inline = 1000,
Id = 100,
@@ -43,6 +46,11 @@ const enum Specificity {
Type = 1,
Universal = 0,
Invalid = 0,
Zero = 0,
/**
* Selector has the specificity of the selector with the highest specificity inside selector list.
*/
SelectorListHighest = -1,
}
const enum Rarity {
@@ -55,6 +63,36 @@ const enum Rarity {
Universal = 0,
Inline = 0,
}
/* eslint-enable @typescript-eslint/no-duplicate-enum-values */
const enum PseudoClassSelectorList {
Regular = 0,
Forgiving = 1,
Relative = 2,
}
enum Combinator {
'descendant' = ' ',
'child' = '>',
'adjacent' = '+',
'sibling' = '~',
// Not supported
'parent' = '<',
'column-combinator' = '||',
}
enum AttributeSelectorOperator {
exists = '',
equals = '=',
start = '^=',
end = '$=',
any = '*=',
element = '~=',
hyphen = '|=',
}
declare type AttributeTest = 'exists' | 'equals' | 'start' | 'end' | 'any' | 'element' | 'hyphen';
interface LookupSorter {
sortById(id: string, sel: SelectorCore);
@@ -74,7 +112,26 @@ namespace Match {
export const Static = false;
}
function getNodeDirectSibling(node): null | Node {
function eachNodePreviousGeneralSibling(node: Node, callback: (sibling: Node) => boolean): void {
if (!node.parent || !node.parent.getChildIndex || !node.parent.getChildAt || !node.parent.getChildrenCount) {
return;
}
const nodeIndex = node.parent.getChildIndex(node);
if (nodeIndex === 0) {
return;
}
const count = node.parent.getChildrenCount();
let retVal: boolean = true;
for (let i = nodeIndex - 1; i >= 0 && retVal; i--) {
const sibling = node.parent.getChildAt(i);
retVal = callback(sibling);
}
}
function getNodePreviousDirectSibling(node: Node): null | Node {
if (!node.parent || !node.parent.getChildIndex || !node.parent.getChildAt) {
return null;
}
@@ -97,19 +154,36 @@ function SelectorProperties(specificity: Specificity, rarity: Rarity, dynamic =
};
}
declare type Combinator = '+' | '>' | '~' | ' ';
@SelectorProperties(Specificity.Universal, Rarity.Universal, Match.Static)
export abstract class SelectorCore {
public pos: number;
public specificity: number;
public rarity: Rarity;
public combinator: Combinator;
public ruleset: RuleSet;
function FunctionalPseudoClassProperties(specificity: Specificity, rarity: Rarity, pseudoSelectorListType: PseudoClassSelectorList): ClassDecorator {
return (cls) => {
cls.prototype.specificity = specificity;
cls.prototype.rarity = rarity;
cls.prototype.combinator = undefined;
cls.prototype.dynamic = false;
cls.prototype.pseudoSelectorListType = pseudoSelectorListType;
return cls;
};
}
export abstract class SelectorBase {
/**
* Dynamic selectors depend on attributes and pseudo classes.
*/
public dynamic: boolean;
public abstract match(node: Node): boolean;
public abstract mayMatch(node: Node): boolean;
public abstract trackChanges(node: Node, map: ChangeAccumulator): void;
}
@SelectorProperties(Specificity.Universal, Rarity.Universal, Match.Static)
export abstract class SelectorCore extends SelectorBase {
public pos: number;
public specificity: number;
public rarity: Rarity;
public combinator: Combinator;
public ruleset: RuleSet;
/**
* If the selector is static returns if it matches the node.
* If the selector is dynamic returns if it may match the node, and accumulates any changes that may affect its state.
@@ -151,7 +225,7 @@ export class InvalidSelector extends SimpleSelector {
super();
}
public toString(): string {
return `<error: ${this.e}>`;
return `<${this.e}>`;
}
public match(node: Node): boolean {
return false;
@@ -221,63 +295,68 @@ export class ClassSelector extends SimpleSelector {
}
}
declare type AttributeTest = '=' | '^=' | '$=' | '*=' | '=' | '~=' | '|=';
@SelectorProperties(Specificity.Attribute, Rarity.Attribute, Match.Dynamic)
export class AttributeSelector extends SimpleSelector {
constructor(public attribute: string, public test?: AttributeTest, public value?: string) {
constructor(
public attribute: string,
public test: AttributeTest,
public value: string,
public ignoreCase: boolean,
) {
super();
if (!test) {
// HasAttribute
this.match = (node) => !isNullOrUndefined(node[attribute]);
return;
}
if (!value) {
this.match = (node) => false;
}
this.match = (node) => {
const attr = node[attribute] + '';
if (test === '=') {
// Equals
return attr === value;
}
if (test === '^=') {
// PrefixMatch
return attr.startsWith(value);
}
if (test === '$=') {
// SuffixMatch
return attr.endsWith(value);
}
if (test === '*=') {
// SubstringMatch
return attr.indexOf(value) !== -1;
}
if (test === '~=') {
// Includes
const words = attr.split(' ');
return words && words.indexOf(value) !== -1;
}
if (test === '|=') {
// DashMatch
return attr === value || attr.startsWith(value + '-');
}
};
}
public toString(): string {
return `[${this.attribute}${wrap(this.test)}${(this.test && this.value) || ''}]${wrap(this.combinator)}`;
return `[${this.attribute}${wrap(AttributeSelectorOperator[this.test] ?? this.test)}${this.value || ''}]${wrap(this.combinator)}`;
}
public match(node: Node): boolean {
let attr = node[this.attribute];
if (this.test === 'exists') {
return !isNullOrUndefined(attr);
}
if (!this.value) {
return false;
}
// Now, convert value to string
attr += '';
if (this.ignoreCase) {
attr = attr.toLowerCase();
this.value = this.value.toLowerCase();
}
// =
if (this.test === 'equals') {
return attr === this.value;
}
// ^=
if (this.test === 'start') {
return attr.startsWith(this.value);
}
// $=
if (this.test === 'end') {
return attr.endsWith(this.value);
}
// *=
if (this.test === 'any') {
return attr.indexOf(this.value) !== -1;
}
// ~=
if (this.test === 'element') {
const words = attr.split(' ');
return words && words.indexOf(this.value) !== -1;
}
// |=
if (this.test === 'hyphen') {
return attr === this.value || attr.startsWith(this.value + '-');
}
return false;
}
public mayMatch(node: Node): boolean {
@@ -307,12 +386,107 @@ export class PseudoClassSelector extends SimpleSelector {
}
}
export abstract class FunctionalPseudoClassSelector extends PseudoClassSelector {
protected selectors: Array<SimpleSelector | SimpleSelectorSequence | ComplexSelector>;
protected selectorListType?: PseudoClassSelectorList;
constructor(cssPseudoClass: string, dataType: CSSWhatDataType) {
super(cssPseudoClass);
const selectors: Array<SimpleSelector | SimpleSelectorSequence | ComplexSelector> = [];
const needsHighestSpecificity: boolean = this.specificity === Specificity.SelectorListHighest;
let specificity: number = 0;
if (Array.isArray(dataType)) {
for (const asts of dataType) {
const selector: SimpleSelector | SimpleSelectorSequence | ComplexSelector = createSelectorFromAst(asts);
if (selector instanceof InvalidSelector) {
// Only forgiving selector list can ignore invalid selectors
if (this.selectorListType !== PseudoClassSelectorList.Forgiving) {
selectors.splice(0);
specificity = 0;
break;
}
continue;
}
// The specificity of some pseudo-classes is replaced by the specificity of the most specific selector in its comma-separated argument of selectors
if (needsHighestSpecificity && selector.specificity > specificity) {
specificity = selector.specificity;
}
selectors.push(selector);
}
}
this.selectors = selectors;
this.specificity = specificity;
// Functional pseudo-classes become dynamic based on selectors in selector list
this.dynamic = this.selectors.some((sel) => sel.dynamic);
}
public toString(): string {
return `:${this.cssPseudoClass}(${this.selectors.join(', ')})${wrap(this.combinator)}`;
}
public match(node: Node): boolean {
return false;
}
public mayMatch(node: Node): boolean {
return true;
}
public trackChanges(node: Node, map: ChangeAccumulator): void {
this.selectors.forEach((sel) => sel.trackChanges(node, map));
}
}
@FunctionalPseudoClassProperties(Specificity.SelectorListHighest, Rarity.PseudoClass, PseudoClassSelectorList.Regular)
export class NotFunctionalPseudoClassSelector extends FunctionalPseudoClassSelector {
public match(node: Node): boolean {
return !this.selectors.some((sel) => sel.match(node));
}
}
@FunctionalPseudoClassProperties(Specificity.SelectorListHighest, Rarity.PseudoClass, PseudoClassSelectorList.Forgiving)
export class IsFunctionalPseudoClassSelector extends FunctionalPseudoClassSelector {
public match(node: Node): boolean {
return this.selectors.some((sel) => sel.match(node));
}
public lookupSort(sorter: LookupSorter, base?: SelectorCore): void {
// A faster lookup can be performed when selector list contains just a single selector
if (this.selectors.length === 1) {
this.selectors[0].lookupSort(sorter, base || this);
} else {
super.lookupSort(sorter, base || this);
}
}
}
@FunctionalPseudoClassProperties(Specificity.Zero, Rarity.PseudoClass, PseudoClassSelectorList.Forgiving)
export class WhereFunctionalPseudoClassSelector extends FunctionalPseudoClassSelector {
public match(node: Node): boolean {
return this.selectors.some((sel) => sel.match(node));
}
public lookupSort(sorter: LookupSorter, base?: SelectorCore): void {
// A faster lookup can be performed when selector list contains just a single selector
if (this.selectors.length === 1) {
this.selectors[0].lookupSort(sorter, base || this);
} else {
super.lookupSort(sorter, base || this);
}
}
}
export class SimpleSelectorSequence extends SimpleSelector {
private head: SimpleSelector;
constructor(public selectors: SimpleSelector[]) {
super();
this.specificity = selectors.reduce((sum, sel) => sel.specificity + sum, 0);
this.head = this.selectors.reduce((prev, curr) => (!prev || curr.rarity > prev.rarity ? curr : prev), null);
this.head = selectors.reduce((prev, curr) => (!prev || curr.rarity > prev.rarity ? curr : prev), null);
this.dynamic = selectors.some((sel) => sel.dynamic);
}
public toString(): string {
@@ -324,7 +498,7 @@ export class SimpleSelectorSequence extends SimpleSelector {
public mayMatch(node: Node): boolean {
return this.selectors.every((sel) => sel.mayMatch(node));
}
public trackChanges(node, map): void {
public trackChanges(node: Node, map: ChangeAccumulator): void {
this.selectors.forEach((sel) => sel.trackChanges(node, map));
}
public lookupSort(sorter: LookupSorter, base?: SelectorCore): void {
@@ -332,32 +506,42 @@ export class SimpleSelectorSequence extends SimpleSelector {
}
}
export class Selector extends SelectorCore {
// Grouped by ancestor combinators, then by direct child combinators.
export class ComplexSelector extends SelectorCore {
// Grouped by ancestor combinators, then by child combinators.
private groups: Selector.ChildGroup[];
private last: SelectorCore;
constructor(public selectors: SimpleSelector[]) {
super();
const supportedCombinator = [undefined, ' ', '>', '+'];
let siblingGroup: SimpleSelector[];
let lastGroup: SimpleSelector[][];
let siblingsToGroup: SimpleSelector[];
let currentGroup: SimpleSelector[][];
const groups: SimpleSelector[][][] = [];
this.specificity = 0;
this.dynamic = false;
for (let i = selectors.length - 1; i > -1; i--) {
for (let i = selectors.length - 1; i >= 0; i--) {
const sel = selectors[i];
if (supportedCombinator.indexOf(sel.combinator) === -1) {
throw new Error(`Unsupported combinator "${sel.combinator}".`);
}
if (sel.combinator === undefined || sel.combinator === ' ') {
groups.push((lastGroup = [(siblingGroup = [])]));
}
if (sel.combinator === '>') {
lastGroup.push((siblingGroup = []));
switch (sel.combinator) {
case undefined:
case Combinator.descendant:
siblingsToGroup = [];
currentGroup = [siblingsToGroup];
groups.push(currentGroup);
break;
case Combinator.child:
siblingsToGroup = [];
currentGroup.push(siblingsToGroup);
break;
case Combinator.adjacent:
case Combinator.sibling:
break;
default:
throw new Error(`Unsupported combinator "${sel.combinator}" for selector ${sel}.`);
}
this.specificity += sel.specificity;
@@ -366,10 +550,10 @@ export class Selector extends SelectorCore {
this.dynamic = true;
}
siblingGroup.push(sel);
siblingsToGroup.push(sel);
}
this.groups = groups.map((g) => new Selector.ChildGroup(g.map((sg) => new Selector.SiblingGroup(sg))));
this.groups = groups.map((g) => new Selector.ChildGroup(g.map((selectors) => (selectors.length > 1 ? new Selector.SiblingGroup(selectors) : selectors[0]))));
this.last = selectors[selectors.length - 1];
}
@@ -380,13 +564,13 @@ export class Selector extends SelectorCore {
public match(node: Node): boolean {
return this.groups.every((group, i) => {
if (i === 0) {
node = group.match(node);
node = group.getMatchingNode(node, true);
return !!node;
} else {
let ancestor = node;
while ((ancestor = ancestor.parent ?? ancestor._modalParent)) {
if ((node = group.match(ancestor))) {
if ((node = group.getMatchingNode(ancestor, true))) {
return true;
}
}
@@ -396,8 +580,16 @@ export class Selector extends SelectorCore {
});
}
public mayMatch(node: Node): boolean {
return false;
}
public trackChanges(node: Node, map: ChangeAccumulator): void {
this.selectors.forEach((sel) => sel.trackChanges(node, map));
}
public lookupSort(sorter: LookupSorter, base?: SelectorCore): void {
this.last.lookupSort(sorter, this);
this.last.lookupSort(sorter, base || this);
}
public accumulateChanges(node: Node, map?: ChangeAccumulator): boolean {
@@ -408,7 +600,7 @@ export class Selector extends SelectorCore {
const bounds: Selector.Bound[] = [];
const mayMatch = this.groups.every((group, i) => {
if (i === 0) {
const nextNode = group.mayMatch(node);
const nextNode = group.getMatchingNode(node, false);
bounds.push({ left: node, right: node });
node = nextNode;
@@ -416,7 +608,7 @@ export class Selector extends SelectorCore {
} else {
let ancestor = node;
while ((ancestor = ancestor.parent)) {
const nextNode = group.mayMatch(ancestor);
const nextNode = group.getMatchingNode(ancestor, false);
if (nextNode) {
bounds.push({ left: ancestor, right: null });
node = nextNode;
@@ -457,44 +649,128 @@ export class Selector extends SelectorCore {
}
export namespace Selector {
// Non-spec. Selector sequences are grouped by ancestor then by child combinators for easier backtracking.
export class ChildGroup {
public dynamic: boolean;
export class ChildGroup extends SelectorBase {
constructor(private selectors: SelectorBase[]) {
super();
constructor(private selectors: SiblingGroup[]) {
this.dynamic = selectors.some((sel) => sel.dynamic);
}
public match(node: Node): Node {
return this.selectors.every((sel, i) => (node = i === 0 ? node : node.parent) && sel.match(node)) ? node : null;
public getMatchingNode(node: Node, strict: boolean) {
const funcName = strict ? 'match' : 'mayMatch';
return this.selectors.every((sel, i) => (node = i === 0 ? node : node.parent) && sel[funcName](node)) ? node : null;
}
public mayMatch(node: Node): Node {
return this.selectors.every((sel, i) => (node = i === 0 ? node : node.parent) && sel.mayMatch(node)) ? node : null;
public match(node: Node): boolean {
return this.getMatchingNode(node, true) != null;
}
public trackChanges(node: Node, map: ChangeAccumulator) {
this.selectors.forEach((sel, i) => (node = i === 0 ? node : node.parent) && sel.trackChanges(node, map));
public mayMatch(node: Node): boolean {
return this.getMatchingNode(node, false) != null;
}
public trackChanges(node: Node, map: ChangeAccumulator): void {
this.selectors.forEach((sel, i) => {
if (i === 0) {
node && sel.trackChanges(node, map);
} else {
node = node.parent;
if (node && sel.mayMatch(node)) {
sel.trackChanges(node, map);
}
}
});
}
}
export class SiblingGroup {
public dynamic: boolean;
export class SiblingGroup extends SelectorBase {
constructor(private selectors: SimpleSelector[]) {
super();
this.dynamic = selectors.some((sel) => sel.dynamic);
}
public match(node: Node): Node {
return this.selectors.every((sel, i) => (node = i === 0 ? node : getNodeDirectSibling(node)) && sel.match(node)) ? node : null;
public match(node: Node): boolean {
return this.selectors.every((sel, i) => {
if (i === 0) {
return node && sel.match(node);
}
if (sel.combinator === Combinator.adjacent) {
node = getNodePreviousDirectSibling(node);
return node && sel.match(node);
}
// Sibling combinator
let isMatching: boolean = false;
eachNodePreviousGeneralSibling(node, (sibling) => {
isMatching = sel.match(sibling);
return !isMatching;
});
return isMatching;
});
}
public mayMatch(node: Node): Node {
return this.selectors.every((sel, i) => (node = i === 0 ? node : getNodeDirectSibling(node)) && sel.mayMatch(node)) ? node : null;
public mayMatch(node: Node): boolean {
return this.selectors.every((sel, i) => {
if (i === 0) {
return node && sel.mayMatch(node);
}
if (sel.combinator === Combinator.adjacent) {
node = getNodePreviousDirectSibling(node);
return node && sel.mayMatch(node);
}
// Sibling combinator
let isMatching: boolean = false;
eachNodePreviousGeneralSibling(node, (sibling) => {
isMatching = sel.mayMatch(sibling);
return !isMatching;
});
return isMatching;
});
}
public trackChanges(node: Node, map: ChangeAccumulator) {
this.selectors.forEach((sel, i) => (node = i === 0 ? node : getNodeDirectSibling(node)) && sel.trackChanges(node, map));
public trackChanges(node: Node, map: ChangeAccumulator): void {
this.selectors.forEach((sel, i) => {
if (i === 0) {
if (node) {
sel.trackChanges(node, map);
}
} else {
if (sel.combinator === Combinator.adjacent) {
node = getNodePreviousDirectSibling(node);
if (node && sel.mayMatch(node)) {
sel.trackChanges(node, map);
}
} else {
// Sibling combinator
let matchingSibling: Node;
eachNodePreviousGeneralSibling(node, (sibling) => {
const isMatching = sel.mayMatch(sibling);
if (isMatching) {
matchingSibling = sibling;
}
return !isMatching;
});
if (matchingSibling) {
sel.trackChanges(matchingSibling, map);
}
}
}
});
}
}
export interface Bound {
left: Node;
right: Node;
@@ -504,7 +780,10 @@ export namespace Selector {
export class RuleSet {
tag: string | number;
scopedTag: string;
constructor(public selectors: SelectorCore[], public declarations: Declaration[]) {
constructor(
public selectors: SelectorCore[],
public declarations: Declaration[],
) {
this.selectors.forEach((sel) => (sel.ruleset = this));
}
public toString(): string {
@@ -528,72 +807,132 @@ function createDeclaration(decl: ReworkCSS.Declaration): any {
return { property: isCssVariable(decl.property) ? decl.property : decl.property.toLowerCase(), value: decl.value };
}
function createSimpleSelectorFromAst(ast: ISimpleSelector): SimpleSelector {
if (ast.type === '.') {
return new ClassSelector(ast.identifier);
}
if (ast.type === '') {
return new TypeSelector(ast.identifier.replace('-', '').toLowerCase());
}
if (ast.type === '#') {
return new IdSelector(ast.identifier);
}
if (ast.type === '[]') {
return new AttributeSelector(ast.property, ast.test, ast.test && ast.value);
}
if (ast.type === ':') {
return new PseudoClassSelector(ast.identifier);
}
if (ast.type === '*') {
return new UniversalSelector();
}
}
function createSimpleSelectorSequenceFromAst(ast: ISimpleSelectorSequence): SimpleSelectorSequence | SimpleSelector {
if (ast.length === 0) {
return new InvalidSelector(new Error('Empty simple selector sequence.'));
} else if (ast.length === 1) {
return createSimpleSelectorFromAst(ast[0]);
} else {
return new SimpleSelectorSequence(ast.map(createSimpleSelectorFromAst));
}
}
function createSelectorFromAst(ast: ISelector): SimpleSelector | SimpleSelectorSequence | Selector {
if (ast.length === 0) {
return new InvalidSelector(new Error('Empty selector.'));
} else if (ast.length === 1) {
return createSimpleSelectorSequenceFromAst(ast[0][0]);
} else {
const simpleSelectorSequences = [];
let simpleSelectorSequence: SimpleSelectorSequence | SimpleSelector;
let combinator: ICombinator;
for (let i = 0; i < ast.length; i++) {
simpleSelectorSequence = createSimpleSelectorSequenceFromAst(<ISimpleSelectorSequence>ast[i][0]);
combinator = <ICombinator>ast[i][1];
if (combinator) {
simpleSelectorSequence.combinator = combinator;
}
simpleSelectorSequences.push(simpleSelectorSequence);
function createSimpleSelectorFromAst(ast: CSSWhatSelector): SimpleSelector {
if (ast.type === 'attribute') {
if (ast.name === 'class') {
return new ClassSelector(ast.value);
}
return new Selector(simpleSelectorSequences);
if (ast.name === 'id') {
return new IdSelector(ast.value);
}
return new AttributeSelector(ast.name, <AttributeTest>ast.action, ast.value, !!ast.ignoreCase);
}
if (ast.type === 'tag') {
return new TypeSelector(ast.name.replace('-', '').toLowerCase());
}
if (ast.type === 'pseudo') {
if (ast.name === 'is') {
return new IsFunctionalPseudoClassSelector(ast.name, ast.data);
}
if (ast.name === 'where') {
return new WhereFunctionalPseudoClassSelector(ast.name, ast.data);
}
if (ast.name === 'not') {
return new NotFunctionalPseudoClassSelector(ast.name, ast.data);
}
return new PseudoClassSelector(ast.name);
}
if (ast.type === 'universal') {
return new UniversalSelector();
}
return new InvalidSelector(new Error(ast.type));
}
export function createSelector(sel: string): SimpleSelector | SimpleSelectorSequence | Selector {
function createSimpleSelectorSequenceFromAst(asts: CSSWhatSelector[]): SimpleSelectorSequence | SimpleSelector {
if (asts.length === 0) {
return new InvalidSelector(new Error('Empty simple selector sequence.'));
}
if (asts.length === 1) {
return createSimpleSelectorFromAst(asts[0]);
}
const sequenceSelectors: SimpleSelector[] = [];
for (const ast of asts) {
const selector = createSimpleSelectorFromAst(ast);
if (selector instanceof InvalidSelector) {
return selector;
}
sequenceSelectors.push(selector);
}
return new SimpleSelectorSequence(sequenceSelectors);
}
function createSelectorFromAst(asts: Array<CSSWhatSelector>): SimpleSelector | SimpleSelectorSequence | ComplexSelector {
let result: SimpleSelector | SimpleSelectorSequence | ComplexSelector;
if (asts.length === 0) {
return new InvalidSelector(new Error('Empty selector.'));
}
if (asts.length === 1) {
return createSimpleSelectorFromAst(asts[0]);
}
const simpleSelectorSequences: Array<SimpleSelector | SimpleSelectorSequence> = [];
let sequenceAsts: CSSWhatSelector[] = [];
let combinatorCount: number = 0;
for (const ast of asts) {
const combinator = Combinator[ast.type];
// Combinator means the end of a sequence
if (combinator != null) {
const selector = createSimpleSelectorSequenceFromAst(sequenceAsts);
if (selector instanceof InvalidSelector) {
return selector;
}
selector.combinator = combinator;
simpleSelectorSequences.push(selector);
combinatorCount++;
// Cleanup stored selectors for the new sequence to take place
sequenceAsts = [];
} else {
sequenceAsts.push(ast);
}
}
if (combinatorCount > 0) {
// Create a sequence using the remaining selectors after the last combinator
if (sequenceAsts.length) {
const selector = createSimpleSelectorSequenceFromAst(sequenceAsts);
if (selector instanceof InvalidSelector) {
return selector;
}
simpleSelectorSequences.push(selector);
}
return new ComplexSelector(simpleSelectorSequences);
}
return createSimpleSelectorSequenceFromAst(sequenceAsts);
}
export function createSelector(sel: string): SimpleSelector | SimpleSelectorSequence | ComplexSelector {
try {
const parsedSelector = parseSelector(sel);
if (!parsedSelector) {
const result = convertToCSSWhatSelector(sel);
if (!result?.length) {
return new InvalidSelector(new Error('Empty selector'));
}
return createSelectorFromAst(parsedSelector.value);
return createSelectorFromAst(result[0]);
} catch (e) {
return new InvalidSelector(e);
}

View File

@@ -734,7 +734,7 @@ function normalizeTransformation({ property, value }: Transformation): Transform
}
function convertTransformValue(property: string, stringValue: string): TransformationValue {
/* eslint-disable prefer-const */
// eslint-disable-next-line prefer-const
let [x, y, z] = stringValue.split(',').map(parseFloat);
if (property === 'translate') {
y ??= IDENTITY_TRANSFORMATION.translate.y;
@@ -1239,8 +1239,8 @@ const boxShadowProperty = new CssProperty<Style, ShadowCSSValues>({
blurRadius: Length.toDevicePixels(newValue.blurRadius, 0),
spreadRadius: Length.toDevicePixels(newValue.spreadRadius, 0),
color: newValue.color,
}
: null
}
: null,
);
},
valueConverter: (value) => {