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:
parent
3846d05f02
commit
a45532fd82
|
|
@ -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',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -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"');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue