Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): New parser for CSS selectors #10514

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5552d65
feat(core): New parser for CSS selectors
CatchABus Apr 13, 2024
0f90fb9
fix: Invalid :not() nested selector parsing
CatchABus Apr 13, 2024
ef4a144
chore: Removed unneeded nullish coalescing operator
CatchABus Apr 13, 2024
2210ae6
fix: Pseudo-class :not() is not dynamic
CatchABus Apr 13, 2024
ed28c8e
feat: Added case-insensitivity support for attribute selectors
CatchABus Apr 13, 2024
2a9487e
fix: Added missing :not() selector change tracking and corrected dyna…
CatchABus Apr 13, 2024
d54e174
feat: Added support for ':is()' pseudo-class
CatchABus Apr 14, 2024
fdbad98
chore: Minor trace improvement
CatchABus Apr 14, 2024
2fca2f6
feat: Added support for pseudo-class selector list types
CatchABus Apr 14, 2024
0748746
feat: Proper functional pseudo-class specificity
CatchABus Apr 14, 2024
39e72da
fix: Added null-check for pseudo-class lookupSort calls
CatchABus Apr 14, 2024
12103ce
fix: Removed pseudo-class lookupSort override as it caused problems
CatchABus Apr 14, 2024
39acfca
feat: Proper dynamic state for functional pseudo-classes
CatchABus Apr 14, 2024
158751c
chore: Removed old css selector parser unused code
CatchABus Apr 14, 2024
3ba567c
chore: Base functional pseudo-class should be abstract
CatchABus Apr 14, 2024
a34b7de
chore: Removed unused method
CatchABus Apr 14, 2024
35b72cb
chore: Re-added the previously removed method
CatchABus Apr 14, 2024
55d2815
perf: Do not generate selector sequences for a single selectors
CatchABus Apr 15, 2024
aeb4fd3
feat: Added combinator support for pseudo-class selector list
CatchABus Apr 15, 2024
e36ce66
feat: Added support for CSS general sibling combination (~)
CatchABus Apr 16, 2024
4a7aa62
chore: Better CSS selector combinator readability
CatchABus Apr 16, 2024
dce0159
chore: Renaming selector arrays
CatchABus Apr 16, 2024
59b4c97
perf: Reduce the number of instances and arrays for complex selectors
CatchABus Apr 20, 2024
f965bfb
test: Added UI sample for general sibling selector
CatchABus Apr 20, 2024
15f4df6
test: Added unit tests for new pseudo-classes
CatchABus Apr 20, 2024
38f0367
chore: Minor comment correction
CatchABus Apr 20, 2024
a3f20cf
fix: Dependency css-what was missing from core
CatchABus Apr 20, 2024
21413fa
perf: Faster lookup for :is() and :where() that contain a single sele…
CatchABus Apr 30, 2024
d5b8fb1
Merge branch 'main' into css-selector-parser-rework
NathanWalker May 2, 2024
97d3bc7
chore: Added missing dependency to root
CatchABus May 2, 2024
82df947
chore: Moving new dependency to root dev deps
CatchABus May 2, 2024
872a0da
test: Added automated tests for new functional pseudo-classes
CatchABus May 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/ui/src/css/combinators-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
33 changes: 33 additions & 0 deletions apps/ui/src/css/combinators-page.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"url": "https://github.com/NativeScript/NativeScript.git"
},
"dependencies": {
"css-what": "^6.1.0",
"nativescript-theme-core": "^1.0.4"
},
"devDependencies": {
Expand Down Expand Up @@ -89,4 +90,3 @@
]
}
}

119 changes: 2 additions & 117 deletions packages/core/css/parser.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
158 changes: 0 additions & 158 deletions packages/core/css/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}