feat(selectors): update css parser (#4565)

This change requires string arguments to be quoted,
for example `:text("foo")` works but `:text(foo)` does not.
This commit is contained in:
Dmitry Gozman 2020-12-02 08:16:02 -08:00 committed by GitHub
parent 3846d05f02
commit a45532fd82
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 57 additions and 50 deletions

View file

@ -16,14 +16,18 @@
import * as css from './cssTokenizer'; import * as css from './cssTokenizer';
// TODO: Consider giving more information, e.g. whether the argument was a quoted string or not.
type ParsedSelectorLiteral = string;
type ClauseCombinator = '' | '>' | '+' | '~'; type ClauseCombinator = '' | '>' | '+' | '~';
export type ParsedSelectorClause = ParsedSelectorLiteral | { css?: string, funcs: { name: string, args: ParsedSelectorList }[] }; // TODO: consider
export type ParsedSelector = { clauses: { clause: ParsedSelectorClause, combinator: ClauseCombinator }[] }; // - key=value
export type ParsedSelectorList = (ParsedSelectorLiteral | ParsedSelector)[]; // - operators like `=`, `|=`, `~=`, `*=`, `/`
// - <empty>~=value
export type CSSFunctionArgument = CSSComplexSelector | number | string;
export type CSSFunction = { name: string, args: CSSFunctionArgument[] };
export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
export type CSSComplexSelector = { simple: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
export type CSSSelectorList = CSSComplexSelector[];
export function parseCSS(selector: string): ParsedSelectorList { export function parseCSS(selector: string): CSSSelectorList {
let tokens: css.CSSTokenInterface[]; let tokens: css.CSSTokenInterface[];
try { try {
tokens = css.tokenize(selector); tokens = css.tokenize(selector);
@ -104,43 +108,46 @@ export function parseCSS(selector: string): ParsedSelectorList {
return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || (tokens[p] instanceof css.WhitespaceToken); return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || (tokens[p] instanceof css.WhitespaceToken);
} }
function consumeSelectorList(): ParsedSelectorList { function consumeFunctionArguments(): CSSFunctionArgument[] {
const result = [consumeSelector()]; const result = [consumeArgument()];
while (true) { while (true) {
skipWhitespace(); skipWhitespace();
if (!isComma()) if (!isComma())
break; break;
pos++; pos++;
result.push(consumeSelector()); result.push(consumeArgument());
} }
return result; return result;
} }
function consumeSelector(): ParsedSelector | ParsedSelectorLiteral { function consumeArgument(): CSSFunctionArgument {
skipWhitespace(); skipWhitespace();
const result = { clauses: [{ clause: consumeSelectorClause(), combinator: '' as ClauseCombinator }] }; if (isNumber())
return tokens[pos++].value;
if (isString())
return tokens[pos++].value;
return consumeComplexSelector();
}
function consumeComplexSelector(): CSSComplexSelector {
skipWhitespace();
const result = { simple: [{ selector: consumeSimpleSelector(), combinator: '' as ClauseCombinator }] };
while (true) { while (true) {
skipWhitespace(); skipWhitespace();
if (isClauseCombinator()) { if (isClauseCombinator()) {
result.clauses[result.clauses.length - 1].combinator = tokens[pos++].value as ClauseCombinator; result.simple[result.simple.length - 1].combinator = tokens[pos++].value as ClauseCombinator;
skipWhitespace(); skipWhitespace();
} else if (isSelectorClauseEnd()) { } else if (isSelectorClauseEnd()) {
break; break;
} }
result.clauses.push({ combinator: '', clause: consumeSelectorClause() }); result.simple.push({ combinator: '', selector: consumeSimpleSelector() });
} }
if (result.clauses.length === 1 && typeof result.clauses[0].clause === 'string')
return result.clauses[0].clause;
return result; return result;
} }
function consumeSelectorClause(): ParsedSelectorClause { function consumeSimpleSelector(): CSSSimpleSelector {
// TODO: Consider symbols like `=`, `|=`, `~=`, `*=`, `/` and convert them to strings.
if ((isNumber() || isString() || isStar() || isIdent()) && isSelectorClauseEnd(pos + 1))
return isString() ? tokens[pos++].value : tokens[pos++].toSource();
let rawCSSString = ''; let rawCSSString = '';
const funcs: { name: string, args: ParsedSelectorList }[] = []; const functions: CSSFunction[] = [];
while (!isSelectorClauseEnd()) { while (!isSelectorClauseEnd()) {
if (isIdent() || isStar()) { if (isIdent() || isStar()) {
@ -156,16 +163,16 @@ export function parseCSS(selector: string): ParsedSelectorList {
} else if (tokens[pos] instanceof css.ColonToken) { } else if (tokens[pos] instanceof css.ColonToken) {
pos++; pos++;
if (isIdent()) { if (isIdent()) {
if (cssFilters.has(tokens[pos].value)) if (builtinCSSFilters.has(tokens[pos].value))
rawCSSString += ':' + tokens[pos++].toSource(); rawCSSString += ':' + tokens[pos++].toSource();
else else
funcs.push({ name: tokens[pos++].value, args: [] }); functions.push({ name: tokens[pos++].value, args: [] });
} else if (tokens[pos] instanceof css.FunctionToken) { } else if (tokens[pos] instanceof css.FunctionToken) {
const name = tokens[pos++].value; const name = tokens[pos++].value;
if (cssFunctions.has(name)) if (builtinCSSFunctions.has(name))
rawCSSString += `:${name}(${consumeCSSFunctionArgs()})`; rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
else else
funcs.push({ name, args: consumeSelectorList() }); functions.push({ name, args: consumeFunctionArguments() });
skipWhitespace(); skipWhitespace();
if (!isCloseParen()) if (!isCloseParen())
throw unexpected(); throw unexpected();
@ -186,37 +193,35 @@ export function parseCSS(selector: string): ParsedSelectorList {
throw unexpected(); throw unexpected();
} }
} }
if (!rawCSSString && !funcs.length) if (!rawCSSString && !functions.length)
throw unexpected(); throw unexpected();
return { css: rawCSSString || undefined, funcs }; return { css: rawCSSString || undefined, functions };
} }
function consumeCSSFunctionArgs(): string { function consumeBuiltinFunctionArguments(): string {
let s = ''; let s = '';
while (!isCloseParen() && !isEOF()) while (!isCloseParen() && !isEOF())
s += tokens[pos++].toSource(); s += tokens[pos++].toSource();
return s; return s;
} }
const result = consumeSelectorList(); const result = consumeFunctionArguments();
if (!isEOF()) if (!isEOF())
throw new Error(`Error while parsing selector "${selector}"`); throw new Error(`Error while parsing selector "${selector}"`);
return result; if (result.some(arg => typeof arg !== 'object' || !('simple' in arg)))
throw new Error(`Error while parsing selector "${selector}"`);
return result as CSSComplexSelector[];
} }
export function serializeSelector(selectorList: ParsedSelectorList) { export function serializeSelector(args: CSSFunctionArgument[]) {
return selectorList.map(selector => { return args.map(arg => {
if (typeof selector === 'string') if (typeof arg === 'string')
return selector; return `"${arg}"`;
return selector.clauses.map(({ clause, combinator }) => { if (typeof arg === 'number')
let s = ''; return String(arg);
if (typeof clause === 'string') { return arg.simple.map(({ selector, combinator }) => {
s = clause; let s = selector.css || '';
} else { s = s + selector.functions.map(func => `:${func.name}(${serializeSelector(func.args)})`).join('');
if (clause.css)
s = clause.css;
s = s + clause.funcs.map(func => `:${func.name}(${serializeSelector(func.args)})`).join('');
}
if (combinator) if (combinator)
s += ' ' + combinator; s += ' ' + combinator;
return s; return s;
@ -224,7 +229,7 @@ export function serializeSelector(selectorList: ParsedSelectorList) {
}).join(', '); }).join(', ');
} }
const cssFilters = new Set([ const builtinCSSFilters = new Set([
'active', 'any-link', 'checked', 'blank', 'default', 'defined', 'active', 'any-link', 'checked', 'blank', 'default', 'defined',
'disabled', 'empty', 'enabled', 'first', 'first-child', 'first-of-type', 'disabled', 'empty', 'enabled', 'first', 'first-child', 'first-of-type',
'fullscreen', 'focus', 'focus-visible', 'focus-within', 'hover', 'fullscreen', 'focus', 'focus-visible', 'focus-within', 'hover',
@ -233,6 +238,6 @@ const cssFilters = new Set([
'read-only', 'read-write', 'required', 'root', 'target', 'valid', 'visited', 'read-only', 'read-write', 'required', 'root', 'target', 'valid', 'visited',
]); ]);
const cssFunctions = new Set([ const builtinCSSFunctions = new Set([
'dir', 'lang', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type', 'dir', 'lang', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type',
]); ]);

View file

@ -57,13 +57,12 @@ it('should parse css', async () => {
expect(serialize(parseCSS('div:is(span):hover'))).toBe('div:hover:is(span)'); expect(serialize(parseCSS('div:is(span):hover'))).toBe('div:hover:is(span)');
expect(serialize(parseCSS('div:scope:hover'))).toBe('div:hover:scope()'); expect(serialize(parseCSS('div:scope:hover'))).toBe('div:hover:scope()');
expect(serialize(parseCSS(':text("foo")'))).toBe(':text(foo)'); expect(serialize(parseCSS(':text("foo")'))).toBe(':text("foo")');
expect(serialize(parseCSS(':text("*")'))).toBe(':text(*)'); expect(serialize(parseCSS(':text("*")'))).toBe(':text("*")');
expect(serialize(parseCSS(':text(*)'))).toBe(':text(*)'); expect(serialize(parseCSS(':text(*)'))).toBe(':text(*)');
expect(serialize(parseCSS(':text("foo", normalize-space)'))).toBe(':text(foo, normalize-space)'); expect(serialize(parseCSS(':text("foo", normalize-space)'))).toBe(':text("foo", normalize-space)');
expect(serialize(parseCSS(':index(3, div span)'))).toBe(':index(3, div span)'); expect(serialize(parseCSS(':index(3, div span)'))).toBe(':index(3, div span)');
expect(serialize(parseCSS(':is(foo, bar>baz.cls+:not(qux))'))).toBe(':is(foo, bar > baz.cls + :not(qux))'); expect(serialize(parseCSS(':is(foo, bar>baz.cls+:not(qux))'))).toBe(':is(foo, bar > baz.cls + :not(qux))');
// expect(serialize(parseCSS(':right-of(div, bar=50)'))).toBe(':right-of(div, bar=50)');
}); });
it('should throw on malformed css', async () => { it('should throw on malformed css', async () => {
@ -96,4 +95,7 @@ it('should throw on malformed css', async () => {
expectError('div > > span'); expectError('div > > span');
expectError('div > > > > span'); expectError('div > > > > span');
expectError('div >'); expectError('div >');
expectError('"foo"');
expectError('23');
expectError('span, div>"foo"');
}); });