playwright/src/server/common/cssParser.ts

239 lines
8.3 KiB
TypeScript
Raw Normal View History

/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 = '' | '>' | '+' | '~';
export type ParsedSelectorClause = ParsedSelectorLiteral | { css?: string, funcs: { name: string, args: ParsedSelectorList }[] };
export type ParsedSelector = { clauses: { clause: ParsedSelectorClause, combinator: ClauseCombinator }[] };
export type ParsedSelectorList = (ParsedSelectorLiteral | ParsedSelector)[];
export function parseCSS(selector: string): ParsedSelectorList {
let tokens: css.CSSTokenInterface[];
try {
tokens = css.tokenize(selector);
if (!(tokens[tokens.length - 1] instanceof css.EOFToken))
tokens.push(new css.EOFToken());
} catch (e) {
const newMessage = e.message + ` while parsing selector "${selector}"`;
const index = (e.stack || '').indexOf(e.message);
if (index !== -1)
e.stack = e.stack.substring(0, index) + newMessage + e.stack.substring(index + e.message.length);
e.message = newMessage;
throw e;
}
const unsupportedToken = tokens.find(token => {
return (token instanceof css.AtKeywordToken) ||
(token instanceof css.BadStringToken) ||
(token instanceof css.BadURLToken) ||
(token instanceof css.ColumnToken) ||
(token instanceof css.CDOToken) ||
(token instanceof css.CDCToken) ||
(token instanceof css.SemicolonToken) ||
// TODO: Consider using these for something, e.g. to escape complex strings.
// For example :xpath{ (//div/bar[@attr="foo"])[2]/baz }
// Or this way :xpath( {complex-xpath-goes-here("hello")} )
(token instanceof css.OpenCurlyToken) ||
(token instanceof css.CloseCurlyToken) ||
// TODO: Consider treating these as strings?
(token instanceof css.URLToken) ||
(token instanceof css.PercentageToken);
});
if (unsupportedToken)
throw new Error(`Unsupported token "${unsupportedToken.toSource()}" while parsing selector "${selector}"`);
let pos = 0;
function unexpected() {
return new Error(`Unexpected token "${tokens[pos].toSource()}" while parsing selector "${selector}"`);
}
function skipWhitespace() {
while (tokens[pos] instanceof css.WhitespaceToken)
pos++;
}
function isIdent(p = pos) {
return tokens[p] instanceof css.IdentToken;
}
function isString(p = pos) {
return tokens[p] instanceof css.StringToken;
}
function isNumber(p = pos) {
return tokens[p] instanceof css.NumberToken;
}
function isComma(p = pos) {
return tokens[p] instanceof css.CommaToken;
}
function isCloseParen(p = pos) {
return tokens[p] instanceof css.CloseParenToken;
}
function isStar(p = pos) {
return (tokens[p] instanceof css.DelimToken) && tokens[p].value === '*';
}
function isEOF(p = pos) {
return tokens[p] instanceof css.EOFToken;
}
function isClauseCombinator(p = pos) {
return (tokens[p] instanceof css.DelimToken) && (['>', '+', '~'].includes(tokens[p].value));
}
function isSelectorClauseEnd(p = pos) {
return isComma(p) || isCloseParen(p) || isEOF(p) || isClauseCombinator(p) || (tokens[p] instanceof css.WhitespaceToken);
}
function consumeSelectorList(): ParsedSelectorList {
const result = [consumeSelector()];
while (true) {
skipWhitespace();
if (!isComma())
break;
pos++;
result.push(consumeSelector());
}
return result;
}
function consumeSelector(): ParsedSelector | ParsedSelectorLiteral {
skipWhitespace();
const result = { clauses: [{ clause: consumeSelectorClause(), combinator: '' as ClauseCombinator }] };
while (true) {
skipWhitespace();
if (isClauseCombinator()) {
result.clauses[result.clauses.length - 1].combinator = tokens[pos++].value as ClauseCombinator;
skipWhitespace();
} else if (isSelectorClauseEnd()) {
break;
}
result.clauses.push({ combinator: '', clause: consumeSelectorClause() });
}
if (result.clauses.length === 1 && typeof result.clauses[0].clause === 'string')
return result.clauses[0].clause;
return result;
}
function consumeSelectorClause(): ParsedSelectorClause {
// 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 = '';
const funcs: { name: string, args: ParsedSelectorList }[] = [];
while (!isSelectorClauseEnd()) {
if (isIdent() || isStar()) {
rawCSSString += tokens[pos++].toSource();
} else if (tokens[pos] instanceof css.HashToken) {
rawCSSString += tokens[pos++].toSource();
} else if ((tokens[pos] instanceof css.DelimToken) && tokens[pos].value === '.') {
pos++;
if (isIdent())
rawCSSString += '.' + tokens[pos++].toSource();
else
throw unexpected();
} else if (tokens[pos] instanceof css.ColonToken) {
pos++;
if (isIdent()) {
if (cssFilters.has(tokens[pos].value))
rawCSSString += ':' + tokens[pos++].toSource();
else
funcs.push({ name: tokens[pos++].value, args: [] });
} else if (tokens[pos] instanceof css.FunctionToken) {
const name = tokens[pos++].value;
if (cssFunctions.has(name))
rawCSSString += `:${name}(${consumeCSSFunctionArgs()})`;
else
funcs.push({ name, args: consumeSelectorList() });
skipWhitespace();
if (!isCloseParen())
throw unexpected();
pos++;
} else {
throw unexpected();
}
} else if (tokens[pos] instanceof css.OpenSquareToken) {
rawCSSString += '[';
pos++;
while (!(tokens[pos] instanceof css.CloseSquareToken) && !isEOF())
rawCSSString += tokens[pos++].toSource();
if (!(tokens[pos] instanceof css.CloseSquareToken))
throw unexpected();
rawCSSString += ']';
pos++;
} else {
throw unexpected();
}
}
if (!rawCSSString && !funcs.length)
throw unexpected();
return { css: rawCSSString || undefined, funcs };
}
function consumeCSSFunctionArgs(): string {
let s = '';
while (!isCloseParen() && !isEOF())
s += tokens[pos++].toSource();
return s;
}
const result = consumeSelectorList();
if (!isEOF())
throw new Error(`Error while parsing selector "${selector}"`);
return result;
}
export function serializeSelector(selectorList: ParsedSelectorList) {
return selectorList.map(selector => {
if (typeof selector === 'string')
return selector;
return selector.clauses.map(({ clause, combinator }) => {
let s = '';
if (typeof clause === 'string') {
s = clause;
} else {
if (clause.css)
s = clause.css;
s = s + clause.funcs.map(func => `:${func.name}(${serializeSelector(func.args)})`).join('');
}
if (combinator)
s += ' ' + combinator;
return s;
}).join(' ');
}).join(', ');
}
const cssFilters = new Set([
'active', 'any-link', 'checked', 'blank', 'default', 'defined',
'disabled', 'empty', 'enabled', 'first', 'first-child', 'first-of-type',
'fullscreen', 'focus', 'focus-visible', 'focus-within', 'hover',
'indeterminate', 'in-range', 'invalid', 'last-child', 'last-of-type',
'link', 'only-child', 'only-of-type', 'optional', 'out-of-range', 'placeholder-shown',
'read-only', 'read-write', 'required', 'root', 'target', 'valid', 'visited',
]);
const cssFunctions = new Set([
'dir', 'lang', 'nth-child', 'nth-last-child', 'nth-last-of-type', 'nth-of-type',
]);