mirror of
https://github.com/microsoft/playwright.git
synced 2025-06-26 21:40:17 +00:00
239 lines
8.3 KiB
TypeScript
239 lines
8.3 KiB
TypeScript
![]() |
/**
|
||
|
* 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',
|
||
|
]);
|