chrome-devtools-frontend
Version:
Chrome DevTools UI
1,325 lines (1,190 loc) • 52.8 kB
text/typescript
// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/* eslint-disable @devtools/no-imperative-dom-api */
import * as Common from '../../core/common/common.js';
import type * as Platform from '../../core/platform/platform.js';
import type * as CodeMirror from '../../third_party/codemirror.next/codemirror.next.js';
import type {CSSMatchedStyles, CSSValueSource, CSSVariableValue} from './CSSMatchedStyles.js';
import {
CSSMetadata,
cssMetadata,
type CSSWideKeyword,
CubicBezierKeywordValues,
} from './CSSMetadata.js';
import type {CSSProperty} from './CSSProperty.js';
import {
ASTUtils,
type BottomUpTreeMatching,
type Match,
matchDeclaration,
matcherBase,
type SyntaxTree,
tokenizeDeclaration
} from './CSSPropertyParser.js';
import type {CSSStyleDeclaration} from './CSSStyleDeclaration.js';
export class BaseVariableMatch implements Match {
constructor(
readonly text: string,
readonly node: CodeMirror.SyntaxNode,
readonly name: string,
readonly fallback: CodeMirror.SyntaxNode[]|undefined,
readonly matching: BottomUpTreeMatching,
readonly computedTextCallback: (match: BaseVariableMatch, matching: BottomUpTreeMatching) => string | null,
) {
}
computedText(): string|null {
return this.computedTextCallback(this, this.matching);
}
fallbackValue(): string|null {
// Fallback can be missing but it can be also be empty: var(--v,)
if (!this.fallback) {
return null;
}
if (this.fallback.length === 0) {
return '';
}
if (this.matching.hasUnresolvedSubstitutionsRange(this.fallback[0], this.fallback[this.fallback.length - 1])) {
return null;
}
return this.matching.getComputedTextRange(this.fallback[0], this.fallback[this.fallback.length - 1]);
}
}
// This matcher provides matching for var() functions and basic computedText support. Computed text is resolved by a
// callback. This matcher is intended to be used directly only in environments where CSSMatchedStyles is not available.
// A more ergonomic version of this matcher exists in VariableMatcher, which uses CSSMatchedStyles to correctly resolve
// variable references automatically.
// clang-format off
export class BaseVariableMatcher extends matcherBase(BaseVariableMatch) {
// clang-format on
readonly #computedTextCallback: (match: BaseVariableMatch, matching: BottomUpTreeMatching) => string | null;
constructor(computedTextCallback: (match: BaseVariableMatch, matching: BottomUpTreeMatching) => string | null) {
super();
this.#computedTextCallback = computedTextCallback;
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): BaseVariableMatch|null {
const callee = node.getChild('Callee');
if (node.name !== 'CallExpression' || !callee || (matching.ast.text(callee) !== 'var')) {
return null;
}
const args = ASTUtils.callArgs(node).map(args => Array.from(ASTUtils.stripComments(args)));
if (args.length < 1 || args[0].length !== 1) {
return null;
}
const nameNode = args[0][0];
const fallback = args.length === 2 ? args[1] : undefined;
if (nameNode?.name !== 'VariableName') {
return null;
}
const varName = matching.ast.text(nameNode);
if (!varName.startsWith('--')) {
return null;
}
return new BaseVariableMatch(
matching.ast.text(node), node, varName, fallback, matching, this.#computedTextCallback);
}
}
export class VariableMatch extends BaseVariableMatch {
constructor(
text: string,
node: CodeMirror.SyntaxNode,
name: string,
fallback: CodeMirror.SyntaxNode[]|undefined,
matching: BottomUpTreeMatching,
readonly matchedStyles: CSSMatchedStyles,
readonly style: CSSStyleDeclaration,
) {
super(text, node, name, fallback, matching, () => this.resolveVariable()?.value ?? this.fallbackValue());
}
resolveVariable(): CSSVariableValue|null {
return this.matchedStyles.computeCSSVariable(this.style, this.name);
}
}
// clang-format off
export class VariableMatcher extends matcherBase(VariableMatch) {
// clang-format on
constructor(readonly matchedStyles: CSSMatchedStyles, readonly style: CSSStyleDeclaration) {
super();
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): VariableMatch|null {
const match = new BaseVariableMatcher(() => null).matches(node, matching);
return match ?
new VariableMatch(
match.text, match.node, match.name, match.fallback, match.matching, this.matchedStyles, this.style) :
null;
}
}
export class AttributeMatch extends BaseVariableMatch {
constructor(
text: string,
node: CodeMirror.SyntaxNode,
name: string,
fallback: CodeMirror.SyntaxNode[]|undefined,
matching: BottomUpTreeMatching,
readonly type: string|null,
readonly isCSSTokens: boolean,
readonly isValidType: boolean,
readonly rawValue: string|null,
readonly substitutionText: string|null,
readonly matchedStyles: CSSMatchedStyles,
readonly style: CSSStyleDeclaration,
computedTextCallback: (match: AttributeMatch, matching: BottomUpTreeMatching) => string | null,
) {
super(text, node, name, fallback, matching, (_, matching) => computedTextCallback(this, matching));
}
rawAttributeValue(): string|null {
return this.rawValue;
}
cssType(): string {
return this.type ?? RAW_STRING_TYPE;
}
resolveAttributeValue(): string|null {
return this.matchedStyles.computeAttribute(
this.style, this.name, {type: this.cssType(), isCSSTokens: this.isCSSTokens});
}
}
let cssEvaluationElement: HTMLElement|null = null;
function getCssEvaluationElement(): HTMLElement {
const id = 'css-evaluation-element';
if (!cssEvaluationElement) {
cssEvaluationElement = document.getElementById(id);
if (!cssEvaluationElement) {
cssEvaluationElement = document.createElement('div');
cssEvaluationElement.setAttribute('id', id);
cssEvaluationElement.setAttribute('style', 'hidden: true; --evaluation: attr(data-custom-expr type(*))');
document.body.appendChild(cssEvaluationElement);
}
}
return cssEvaluationElement;
}
/**
* These functions use an element in the frontend to evaluate CSS. The advantage
* of this is that it is synchronous and doesn't require a CDP method. The
* disadvantage is it lacks context that would allow substitutions such as
* `var()` and `calc()` to be resolved correctly, and if the user is doing
* remote debugging there is a possibility that the CSS behavior is different
* between the two browser versions. We use it for type checking after
* substitutions (but not for actual evaluation) and for applying units.
**/
export function localEvalCSS(value: string, type: string): string|null {
const element = getCssEvaluationElement();
element.setAttribute('data-value', value);
element.setAttribute('data-custom-expr', `attr(data-value ${type})`);
return element.computedStyleMap().get('--evaluation')?.toString() ?? null;
}
/**
* It is important to establish whether a type is valid, because if it is not,
* the current behavior of blink is to ignore the fallback and parse as a
* raw string, returning '' if the attribute is not set.
**/
export function isValidCSSType(type: string): boolean {
const element = getCssEvaluationElement();
element.setAttribute('data-custom-expr', `attr(data-nonexistent ${type}, "good")`);
return '"good"' === (element.computedStyleMap().get('--evaluation')?.toString() ?? null);
}
export function defaultValueForCSSType(type: string|null): string|null {
const element = getCssEvaluationElement();
element.setAttribute('data-custom-expr', `attr(data-nonexistent ${type ?? ''})`);
return element.computedStyleMap().get('--evaluation')?.toString() ?? null;
}
export const RAW_STRING_TYPE = 'raw-string';
// This matcher provides matching for attr() functions and basic computedText support. Computed text is resolved by a
// callback.
// clang-format off
export class AttributeMatcher extends matcherBase(AttributeMatch) {
// clang-format on
constructor(
private readonly matchedStyles: CSSMatchedStyles,
private readonly style: CSSStyleDeclaration,
private readonly computedTextCallback?: (match: AttributeMatch, matching: BottomUpTreeMatching) => string | null,
) {
super();
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): AttributeMatch|null {
const callee = node.getChild('Callee');
if (node.name !== 'CallExpression' || !callee || (matching.ast.text(callee) !== 'attr')) {
return null;
}
const args = ASTUtils.callArgs(node).map(args => Array.from(ASTUtils.stripComments(args)));
if (args.length < 1) {
return null;
}
const nameNode = args[0][0];
if (args[0].length < 1 || args[0].length > 2 || nameNode?.name !== 'ValueName') {
return null;
}
const fallback = args.length === 2 ? args[1] : undefined;
let type: string|null = null;
let isCSSTokens = false;
if (args[0].length === 2) {
const typeNode = args[0][1] as CodeMirror.SyntaxNode;
type = matching.ast.text(typeNode);
if (typeNode.name === 'CallExpression') {
if (matching.ast.text(typeNode.getChild('Callee')) !== 'type') {
return null;
}
isCSSTokens = true;
} else if (typeNode.name !== 'ValueName' && type !== '%') {
return null;
}
}
const isValidType = type === null || isValidCSSType(type);
isCSSTokens = isCSSTokens && isValidType;
const attrName = matching.ast.text(nameNode);
const rawValue = this.matchedStyles.rawAttributeValueFromStyle(this.style, attrName);
let substitutionText: string|null = null;
if (rawValue !== null) {
substitutionText = isCSSTokens ? rawValue : localEvalCSS(rawValue, type ?? RAW_STRING_TYPE);
} else if (!fallback) {
// In the case of unspecified type, there is a default value
substitutionText = defaultValueForCSSType(type);
}
return new AttributeMatch(
matching.ast.text(node), node, attrName, fallback, matching, type, isCSSTokens, isValidType, rawValue,
substitutionText, this.matchedStyles, this.style, this.computedTextCallback ?? defaultComputeText);
function defaultComputeText(match: AttributeMatch, _matching: BottomUpTreeMatching): string|null {
// Don't fall back if the type is invalid.
return match.resolveAttributeValue() ??
(isValidType ? match.fallbackValue() : defaultValueForCSSType(match.type));
}
}
}
export class BinOpMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode) {
}
}
// clang-format off
export class BinOpMatcher extends matcherBase(BinOpMatch) {
// clang-format on
override accepts(): boolean {
return true;
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): BinOpMatch|null {
return node.name === 'BinaryExpression' ? new BinOpMatch(matching.ast.text(node), node) : null;
}
}
export class TextMatch implements Match {
computedText?: () => string;
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode) {
if (node.name === 'Comment') {
this.computedText = () => '';
}
}
render(): Node[] {
const span = document.createElement('span');
span.appendChild(document.createTextNode(this.text));
return [span];
}
}
// clang-format off
export class TextMatcher extends matcherBase(TextMatch) {
// clang-format on
override accepts(): boolean {
return true;
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): TextMatch|null {
if (!node.firstChild || node.name === 'NumberLiteral' /* may have a Unit child */) {
// Leaf node, just emit text
const text = matching.ast.text(node);
if (text.length) {
return new TextMatch(text, node);
}
}
return null;
}
}
export class AngleMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode) {
}
computedText(): string {
return this.text;
}
}
// clang-format off
export class AngleMatcher extends matcherBase(AngleMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isAngleAwareProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): AngleMatch|null {
if (node.name !== 'NumberLiteral') {
return null;
}
const unit = node.getChild('Unit');
// TODO(crbug/1138628) handle unitless 0
if (!unit || !['deg', 'grad', 'rad', 'turn'].includes(matching.ast.text(unit))) {
return null;
}
return new AngleMatch(matching.ast.text(node), node);
}
}
function literalToNumber(node: CodeMirror.SyntaxNode, ast: SyntaxTree): number|null {
if (node.type.name !== 'NumberLiteral') {
return null;
}
const text = ast.text(node);
return Number(text.substring(0, text.length - ast.text(node.getChild('Unit')).length));
}
export class ColorMixMatch implements Match {
constructor(
readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly space: CodeMirror.SyntaxNode[],
readonly color1: CodeMirror.SyntaxNode[], readonly color2: CodeMirror.SyntaxNode[]) {
}
}
// clang-format off
export class ColorMixMatcher extends matcherBase(ColorMixMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isColorAwareProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): ColorMixMatch|null {
if (node.name !== 'CallExpression' || matching.ast.text(node.getChild('Callee')) !== 'color-mix') {
return null;
}
const computedValueTree = tokenizeDeclaration('--property', matching.getComputedText(node));
if (!computedValueTree) {
return null;
}
const value = ASTUtils.declValue(computedValueTree.tree);
if (!value) {
return null;
}
const computedValueArgs = ASTUtils.callArgs(value);
if (computedValueArgs.length !== 3) {
return null;
}
const [space, color1, color2] = computedValueArgs;
// Verify that all arguments are there, and that the space starts with a literal `in`.
if (space.length < 2 || computedValueTree.text(ASTUtils.stripComments(space).next().value) !== 'in' ||
color1.length < 1 || color2.length < 1) {
return null;
}
// Verify there's at most one percentage value for each color.
const p1 =
color1.filter(n => n.type.name === 'NumberLiteral' && computedValueTree.text(n.getChild('Unit')) === '%');
const p2 =
color2.filter(n => n.type.name === 'NumberLiteral' && computedValueTree.text(n.getChild('Unit')) === '%');
if (p1.length > 1 || p2.length > 1) {
return null;
}
// Verify that if both colors carry percentages, they aren't both zero (which is an invalid property value).
if (p1[0] && p2[0] && (literalToNumber(p1[0], computedValueTree) ?? 0) === 0 &&
(literalToNumber(p2[0], computedValueTree) ?? 0) === 0) {
return null;
}
const args = ASTUtils.callArgs(node);
if (args.length !== 3) {
return null;
}
return new ColorMixMatch(matching.ast.text(node), node, args[0], args[1], args[2]);
}
}
// clang-format off
export class URLMatch implements Match {
constructor(
readonly url: Platform.DevToolsPath.UrlString, readonly text: string, readonly node: CodeMirror.SyntaxNode) {
}
}
// clang-format off
export class URLMatcher extends matcherBase(URLMatch) {
// clang-format on
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): URLMatch|null {
if (node.name !== 'CallLiteral') {
return null;
}
const callee = node.getChild('CallTag');
if (!callee || matching.ast.text(callee) !== 'url') {
return null;
}
const [, lparenNode, urlNode, rparenNode] = ASTUtils.siblings(callee);
if (matching.ast.text(lparenNode) !== '(' ||
(urlNode.name !== 'ParenthesizedContent' && urlNode.name !== 'StringLiteral') ||
matching.ast.text(rparenNode) !== ')') {
return null;
}
const text = matching.ast.text(urlNode);
const url = (urlNode.name === 'StringLiteral' ? text.substr(1, text.length - 2) : text.trim()) as
Platform.DevToolsPath.UrlString;
return new URLMatch(url, matching.ast.text(node), node);
}
}
export class LinearGradientMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode) {
}
}
// clang-format off
export class LinearGradientMatcher extends matcherBase(LinearGradientMatch) {
// clang-format on
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): Match|null {
const text = matching.ast.text(node);
if (node.name === 'CallExpression' && matching.ast.text(node.getChild('Callee')) === 'linear-gradient') {
return new LinearGradientMatch(text, node);
}
return null;
}
override accepts(propertyName: string): boolean {
return ['background', 'background-image', '-webkit-mask-image'].includes(propertyName);
}
}
interface RelativeColor {
colorSpace: Common.Color.Format;
baseColor: ColorMatch;
}
export class ColorMatch implements Match {
computedText: (() => string | null)|undefined;
constructor(
readonly text: string, readonly node: CodeMirror.SyntaxNode,
private readonly currentColorCallback?: () => string | null, readonly relativeColor?: RelativeColor) {
this.computedText = currentColorCallback;
}
}
// clang-format off
export class ColorMatcher extends matcherBase(ColorMatch) {
constructor(private readonly currentColorCallback?: () => string|null) {
super();
}
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isColorAwareProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): ColorMatch|null {
const text = matching.ast.text(node);
if (node.name === 'ColorLiteral') {
return new ColorMatch(text, node);
}
if (node.name === 'ValueName') {
if (Common.Color.Nicknames.has(text)) {
return new ColorMatch(text, node);
}
if (text.toLowerCase() === 'currentcolor' && this.currentColorCallback) {
const callback = this.currentColorCallback;
return new ColorMatch(text, node, () => callback() ?? text);
}
}
if (node.name === 'CallExpression') {
const callee = node.getChild('Callee');
const colorFunc = matching.ast.text(callee).toLowerCase();
if (callee && colorFunc.match(/^(rgba?|hsla?|hwba?|lab|lch|oklab|oklch|color)$/)) {
const args = ASTUtils.children(node.getChild('ArgList'));
// args are the tokens for the parthesized expression following the function name, so in a well-formed case
// should at least contain the open and closing parens.
const colorText = args.length >= 2 ? matching.getComputedTextRange(args[0], args[args.length - 1]) : '';
// colorText holds the fully substituted parenthesized expression, so colorFunc + colorText is the color
// function call.
const isRelativeColorSyntax = Boolean(
colorText.match(/^[^)]*\(\W*from\W+/) && !matching.hasUnresolvedSubstitutions(node) &&
CSS.supports('color', colorFunc + colorText));
if (!isRelativeColorSyntax) {
return new ColorMatch(text, node);
}
const tokenized = matchDeclaration('--color', '--colorFunc' + colorText, [new ColorMatcher()]);
if (!tokenized) {
return null;
}
const [colorArgs] = ASTUtils.callArgs(ASTUtils.declValue(tokenized.ast.tree));
// getComputedText already removed comments and such, so there must be 5 or 6 args:
// rgb(from red c0 c1 c2) or color(from yellow srgb c0 c1 c2)
// If any of the C is a calc expression that is a single root node. If the value contains an alpha channel that
// is parsed as a BinOp into c2.
if (colorArgs.length !== (colorFunc === 'color' ? 6 : 5)) {
return null;
}
const colorSpace = Common.Color.getFormat(colorFunc !== 'color' ? colorFunc : matching.ast.text(colorArgs[2]));
if (!colorSpace) {
return null;
}
const baseColor = tokenized.getMatch(colorArgs[1]);
if (tokenized.ast.text(colorArgs[0]) !== 'from' || !(baseColor instanceof ColorMatch)) {
return null;
}
return new ColorMatch(text, node, undefined, {colorSpace, baseColor});
}
}
return null;
}
}
function isRelativeColorChannelName(channel: string): channel is Common.Color.ColorChannel {
const maybeChannel = channel as Common.Color.ColorChannel;
switch (maybeChannel) {
case Common.Color.ColorChannel.A:
case Common.Color.ColorChannel.ALPHA:
case Common.Color.ColorChannel.B:
case Common.Color.ColorChannel.C:
case Common.Color.ColorChannel.G:
case Common.Color.ColorChannel.H:
case Common.Color.ColorChannel.L:
case Common.Color.ColorChannel.R:
case Common.Color.ColorChannel.S:
case Common.Color.ColorChannel.W:
case Common.Color.ColorChannel.X:
case Common.Color.ColorChannel.Y:
case Common.Color.ColorChannel.Z:
return true;
}
// This assignment catches missed values in the switch above.
const catchFallback: never = maybeChannel; // eslint-disable-line @typescript-eslint/no-unused-vars
return false;
}
export class RelativeColorChannelMatch implements Match {
constructor(readonly text: Common.Color.ColorChannel, readonly node: CodeMirror.SyntaxNode) {
}
getColorChannelValue(relativeColor: RelativeColor): number|null {
const color = Common.Color.parse(relativeColor.baseColor.text)?.as(relativeColor.colorSpace);
if (color instanceof Common.Color.ColorFunction) {
switch (this.text) {
case Common.Color.ColorChannel.R:
return color.isXYZ() ? null : color.p0;
case Common.Color.ColorChannel.G:
return color.isXYZ() ? null : color.p1;
case Common.Color.ColorChannel.B:
return color.isXYZ() ? null : color.p2;
case Common.Color.ColorChannel.X:
return color.isXYZ() ? color.p0 : null;
case Common.Color.ColorChannel.Y:
return color.isXYZ() ? color.p1 : null;
case Common.Color.ColorChannel.Z:
return color.isXYZ() ? color.p2 : null;
case Common.Color.ColorChannel.ALPHA:
return color.alpha;
}
} else if (color instanceof Common.Color.Legacy) {
switch (this.text) {
case Common.Color.ColorChannel.R:
return color.rgba()[0];
case Common.Color.ColorChannel.G:
return color.rgba()[1];
case Common.Color.ColorChannel.B:
return color.rgba()[2];
case Common.Color.ColorChannel.ALPHA:
return color.rgba()[3];
}
} else if (color && this.text in color) {
return color[this.text as keyof typeof color] as number;
}
return null;
}
computedText(): string {
return this.text;
}
}
// clang-format off
export class RelativeColorChannelMatcher extends matcherBase(RelativeColorChannelMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isColorAwareProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): RelativeColorChannelMatch|null {
const text = matching.ast.text(node);
if (node.name === 'ValueName' && isRelativeColorChannelName(text)) {
return new RelativeColorChannelMatch(text, node);
}
return null;
}
}
export class LightDarkColorMatch implements Match {
constructor(
readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly light: CodeMirror.SyntaxNode[],
readonly dark: CodeMirror.SyntaxNode[], readonly style: CSSStyleDeclaration) {
}
}
// clang-format off
export class LightDarkColorMatcher extends matcherBase(LightDarkColorMatch) {
// clang-format on
constructor(readonly style: CSSStyleDeclaration) {
super();
}
override accepts(propertyName: string): boolean {
return cssMetadata().isColorAwareProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): LightDarkColorMatch|null {
if (node.name !== 'CallExpression' || matching.ast.text(node.getChild('Callee')) !== 'light-dark') {
return null;
}
const args = ASTUtils.callArgs(node);
if (args.length !== 2 || args[0].length === 0 || args[1].length === 0) {
return null;
}
return new LightDarkColorMatch(matching.ast.text(node), node, args[0], args[1], this.style);
}
}
export class AutoBaseMatch implements Match {
constructor(
readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly auto: CodeMirror.SyntaxNode[],
readonly base: CodeMirror.SyntaxNode[]) {
}
}
// clang-format off
export class AutoBaseMatcher extends matcherBase(AutoBaseMatch) {
// clang-format on
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): AutoBaseMatch|null {
if (node.name !== 'CallExpression' || matching.ast.text(node.getChild('Callee')) !== '-internal-auto-base') {
return null;
}
const args = ASTUtils.callArgs(node);
if (args.length !== 2 || args[0].length === 0 || args[1].length === 0) {
return null;
}
return new AutoBaseMatch(matching.ast.text(node), node, args[0], args[1]);
}
}
export const enum LinkableNameProperties {
ANIMATION = 'animation',
ANIMATION_NAME = 'animation-name',
FONT_PALETTE = 'font-palette',
POSITION_TRY_FALLBACKS = 'position-try-fallbacks',
POSITION_TRY = 'position-try',
}
const enum AnimationLonghandPart {
DIRECTION = 'direction',
FILL_MODE = 'fill-mode',
PLAY_STATE = 'play-state',
ITERATION_COUNT = 'iteration-count',
EASING_FUNCTION = 'easing-function',
}
export class LinkableNameMatch implements Match {
constructor(
readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly propertyName: LinkableNameProperties) {
}
}
// clang-format off
export class LinkableNameMatcher extends matcherBase(LinkableNameMatch) {
// clang-format on
private static isLinkableNameProperty(propertyName: string): propertyName is LinkableNameProperties {
const names: string[] = [
LinkableNameProperties.ANIMATION,
LinkableNameProperties.ANIMATION_NAME,
LinkableNameProperties.FONT_PALETTE,
LinkableNameProperties.POSITION_TRY_FALLBACKS,
LinkableNameProperties.POSITION_TRY,
];
return names.includes(propertyName);
}
static readonly identifierAnimationLonghandMap = new Map<string, AnimationLonghandPart>(
Object.entries({
normal: AnimationLonghandPart.DIRECTION,
alternate: AnimationLonghandPart.DIRECTION,
reverse: AnimationLonghandPart.DIRECTION,
'alternate-reverse': AnimationLonghandPart.DIRECTION,
none: AnimationLonghandPart.FILL_MODE,
forwards: AnimationLonghandPart.FILL_MODE,
backwards: AnimationLonghandPart.FILL_MODE,
both: AnimationLonghandPart.FILL_MODE,
running: AnimationLonghandPart.PLAY_STATE,
paused: AnimationLonghandPart.PLAY_STATE,
infinite: AnimationLonghandPart.ITERATION_COUNT,
linear: AnimationLonghandPart.EASING_FUNCTION,
ease: AnimationLonghandPart.EASING_FUNCTION,
'ease-in': AnimationLonghandPart.EASING_FUNCTION,
'ease-out': AnimationLonghandPart.EASING_FUNCTION,
'ease-in-out': AnimationLonghandPart.EASING_FUNCTION,
steps: AnimationLonghandPart.EASING_FUNCTION,
'step-start': AnimationLonghandPart.EASING_FUNCTION,
'step-end': AnimationLonghandPart.EASING_FUNCTION,
}),
);
private matchAnimationNameInShorthand(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): LinkableNameMatch|
null {
// Order is important within each animation definition for distinguishing <keyframes-name> values from other keywords.
// When parsing, keywords that are valid for properties other than animation-name
// whose values were not found earlier in the shorthand must be accepted for those properties rather than for animation-name.
// See the details in: https://w3c.github.io/csswg-drafts/css-animations/#animation.
const text = matching.ast.text(node);
// This is not a known identifier, so return it as `animation-name`.
if (!LinkableNameMatcher.identifierAnimationLonghandMap.has(text)) {
return new LinkableNameMatch(text, node, LinkableNameProperties.ANIMATION);
}
// There can be multiple `animation` declarations splitted by a comma.
// So, we find the declaration nodes that are related to the node argument.
const declarations = ASTUtils.split(ASTUtils.siblings(ASTUtils.declValue(matching.ast.tree)));
const currentDeclarationNodes = declarations.find(
declaration => declaration[0].from <= node.from && declaration[declaration.length - 1].to >= node.to);
if (!currentDeclarationNodes) {
return null;
}
// We reparse here until the node argument since a variable might be
// providing a meaningful value such as a timing keyword,
// that might change the meaning of the node.
const computedText = matching.getComputedTextRange(currentDeclarationNodes[0], node);
const tokenized = tokenizeDeclaration('--p', computedText);
if (!tokenized) {
return null;
}
const identifierCategory =
LinkableNameMatcher.identifierAnimationLonghandMap.get(text); // The category of the node argument
for (let itNode: typeof tokenized.tree|null = ASTUtils.declValue(tokenized.tree); itNode?.nextSibling;
itNode = itNode.nextSibling) {
// Run through all the nodes that come before node argument
// and check whether a value in the same category is found.
// if so, it means our identifier is an `animation-name` keyword.
if (itNode.name === 'ValueName') {
const categoryValue = LinkableNameMatcher.identifierAnimationLonghandMap.get(tokenized.text(itNode));
if (categoryValue && categoryValue === identifierCategory) {
return new LinkableNameMatch(text, node, LinkableNameProperties.ANIMATION);
}
}
}
return null;
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): LinkableNameMatch|null {
const {propertyName} = matching.ast;
const text = matching.ast.text(node);
const parentNode = node.parent;
if (!parentNode) {
return null;
}
if (!(propertyName && LinkableNameMatcher.isLinkableNameProperty(propertyName))) {
return null;
}
const isParentADeclaration = parentNode.name === 'Declaration';
const isInsideVarCall = parentNode.name === 'ArgList' && parentNode.prevSibling?.name === 'Callee' &&
matching.ast.text(parentNode.prevSibling) === 'var';
const isAParentDeclarationOrVarCall = isParentADeclaration || isInsideVarCall;
// `position-try-fallbacks` and `position-try` only accept names with dashed ident.
const shouldMatchOnlyVariableName = propertyName === LinkableNameProperties.POSITION_TRY ||
propertyName === LinkableNameProperties.POSITION_TRY_FALLBACKS;
// We only mark top level nodes or nodes that are inside `var()` expressions as linkable names.
if (!propertyName || (node.name !== 'ValueName' && node.name !== 'VariableName') ||
!isAParentDeclarationOrVarCall || (node.name === 'ValueName' && shouldMatchOnlyVariableName)) {
return null;
}
if (propertyName === 'animation') {
return this.matchAnimationNameInShorthand(node, matching);
}
// The assertion here is safe since this matcher only runs for
// properties with names inside `LinkableNameProperties` (See the `accepts` function.)
return new LinkableNameMatch(text, node, propertyName as LinkableNameProperties);
}
}
export class BezierMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode) {
}
}
// clang-format off
export class BezierMatcher extends matcherBase(BezierMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isBezierAwareProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): Match|null {
const text = matching.ast.text(node);
const isCubicBezierKeyword = node.name === 'ValueName' && CubicBezierKeywordValues.has(text);
const isCubicBezierOrLinearFunction = node.name === 'CallExpression' &&
['cubic-bezier', 'linear'].includes(matching.ast.text(node.getChild('Callee')));
if (!isCubicBezierKeyword && !isCubicBezierOrLinearFunction) {
return null;
}
return new BezierMatch(text, node);
}
}
export class StringMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode) {
}
}
// clang-format off
export class StringMatcher extends matcherBase(StringMatch) {
// clang-format on
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): Match|null {
return node.name === 'StringLiteral' ? new StringMatch(matching.ast.text(node), node) : null;
}
}
export const enum ShadowType {
BOX_SHADOW = 'boxShadow',
TEXT_SHADOW = 'textShadow',
}
export class ShadowMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly shadowType: ShadowType) {
}
}
// clang-format off
export class ShadowMatcher extends matcherBase(ShadowMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isShadowProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): ShadowMatch|null {
if (node.name !== 'Declaration') {
return null;
}
const valueNodes = ASTUtils.siblings(ASTUtils.declValue(node));
if (valueNodes.length === 0) {
return null;
}
const valueText = matching.ast.textRange(valueNodes[0], valueNodes[valueNodes.length - 1]);
return new ShadowMatch(
valueText, node, matching.ast.propertyName === 'text-shadow' ? ShadowType.TEXT_SHADOW : ShadowType.BOX_SHADOW);
}
}
export class FontMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode) {
}
}
// clang-format off
export class FontMatcher extends matcherBase(FontMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isFontAwareProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): Match|null {
if (node.name !== 'Declaration') {
return null;
}
const valueNodes = ASTUtils.siblings(ASTUtils.declValue(node));
if (valueNodes.length === 0) {
return null;
}
const validNodes = matching.ast.propertyName === 'font-family' ? ['ValueName', 'StringLiteral', 'Comment', ','] :
['Comment', 'ValueName', 'NumberLiteral'];
if (valueNodes.some(node => !validNodes.includes(node.name))) {
return null;
}
const valueText = matching.ast.textRange(valueNodes[0], valueNodes[valueNodes.length - 1]);
return new FontMatch(valueText, node);
}
}
export class LengthMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly unit: string) {
}
}
// clang-format off
export class LengthMatcher extends matcherBase(LengthMatch) {
// clang-format on
static readonly LENGTH_UNITS = new Set([
'em', 'ex', 'ch', 'cap', 'ic', 'lh', 'rem', 'rex', 'rch', 'rlh', 'ric', 'rcap', 'pt', 'pc',
'in', 'cm', 'mm', 'Q', 'vw', 'vh', 'vi', 'vb', 'vmin', 'vmax', 'dvw', 'dvh', 'dvi', 'dvb',
'dvmin', 'dvmax', 'svw', 'svh', 'svi', 'svb', 'svmin', 'svmax', 'lvw', 'lvh', 'lvi', 'lvb', 'lvmin', 'lvmax',
'cqw', 'cqh', 'cqi', 'cqb', 'cqmin', 'cqmax', 'cqem', 'cqlh', 'cqex', 'cqch', '%'
]);
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): LengthMatch|null {
if (node.name !== 'NumberLiteral') {
return null;
}
const unit = matching.ast.text(node.getChild('Unit'));
if (!LengthMatcher.LENGTH_UNITS.has(unit)) {
return null;
}
const text = matching.ast.text(node);
return new LengthMatch(text, node, unit);
}
}
export const enum SelectFunction {
MIN = 'min',
MAX = 'max',
CLAMP = 'clamp',
}
export const enum ArithmeticFunction {
CALC = 'calc',
SIBLING_COUNT = 'sibling-count',
SIBLING_INDEX = 'sibling-index',
}
type MathFunction = SelectFunction|ArithmeticFunction;
export class BaseFunctionMatch<T extends string> implements Match {
constructor(
readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly func: T,
readonly args: CodeMirror.SyntaxNode[][]) {
}
}
export class MathFunctionMatch extends BaseFunctionMatch<MathFunction> {
isArithmeticFunctionCall(): boolean {
const func = this.func as ArithmeticFunction;
switch (func) {
case ArithmeticFunction.CALC:
case ArithmeticFunction.SIBLING_COUNT:
case ArithmeticFunction.SIBLING_INDEX:
return true;
}
// This assignment catches missed values in the switch above.
const catchFallback: never = func; // eslint-disable-line @typescript-eslint/no-unused-vars
return false;
}
}
// clang-format off
export class MathFunctionMatcher extends matcherBase(MathFunctionMatch) {
// clang-format on
private static getFunctionType(callee: string|null): MathFunction|null {
const maybeFunc = callee as MathFunction | null;
switch (maybeFunc) {
case null:
case SelectFunction.MIN:
case SelectFunction.MAX:
case SelectFunction.CLAMP:
case ArithmeticFunction.CALC:
case ArithmeticFunction.SIBLING_COUNT:
case ArithmeticFunction.SIBLING_INDEX:
return maybeFunc;
}
// This assignment catches missed values in the switch above.
const catchFallback: never = maybeFunc; // eslint-disable-line @typescript-eslint/no-unused-vars
return null;
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): MathFunctionMatch|null {
if (node.name !== 'CallExpression') {
return null;
}
const callee = MathFunctionMatcher.getFunctionType(matching.ast.text(node.getChild('Callee')));
if (!callee) {
return null;
}
const args = ASTUtils.callArgs(node);
if (args.some(arg => arg.length === 0 || matching.hasUnresolvedSubstitutionsRange(arg[0], arg[arg.length - 1]))) {
return null;
}
const text = matching.ast.text(node);
const match = new MathFunctionMatch(text, node, callee, args);
if (!match.isArithmeticFunctionCall() && args.length === 0) {
return null;
}
return match;
}
}
export class CustomFunctionMatch extends BaseFunctionMatch<string> {}
// clang-format off
export class CustomFunctionMatcher extends matcherBase(CustomFunctionMatch) {
// clang-format on
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): CustomFunctionMatch|null {
if (node.name !== 'CallExpression') {
return null;
}
const callee = matching.ast.text(node.getChild('VariableName'));
if (!callee?.startsWith('--')) {
return null;
}
const args = ASTUtils.callArgs(node);
if (args.some(arg => arg.length === 0 || matching.hasUnresolvedSubstitutionsRange(arg[0], arg[arg.length - 1]))) {
return null;
}
const text = matching.ast.text(node);
return new CustomFunctionMatch(text, node, callee, args);
}
}
export const enum LayoutType {
FLEX = 'flex',
GRID = 'grid',
GRID_LANES = 'grid-lanes',
}
export class FlexGridGridLanesMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly layoutType: LayoutType) {
}
}
// clang-format off
export class FlexGridGridLanesMatcher extends matcherBase(FlexGridGridLanesMatch) {
// clang-format on
static readonly FLEX = ['flex', 'inline-flex', 'block flex', 'inline flex'];
static readonly GRID = ['grid', 'inline-grid', 'block grid', 'inline grid'];
static readonly GRID_LANES = ['grid-lanes', 'inline-grid-lanes', 'block grid-lanes', 'inline grid-lanes'];
override accepts(propertyName: string): boolean {
return propertyName === 'display';
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): FlexGridGridLanesMatch|null {
if (node.name !== 'Declaration') {
return null;
}
const valueNodes = ASTUtils.siblings(ASTUtils.declValue(node));
if (valueNodes.length < 1) {
return null;
}
const values = valueNodes.filter(node => node.name !== 'Important')
.map(node => matching.getComputedText(node).trim())
.filter(value => value);
const text = values.join(' ');
if (FlexGridGridLanesMatcher.FLEX.includes(text)) {
return new FlexGridGridLanesMatch(matching.ast.text(node), node, LayoutType.FLEX);
}
if (FlexGridGridLanesMatcher.GRID.includes(text)) {
return new FlexGridGridLanesMatch(matching.ast.text(node), node, LayoutType.GRID);
}
if (FlexGridGridLanesMatcher.GRID_LANES.includes(text)) {
return new FlexGridGridLanesMatch(matching.ast.text(node), node, LayoutType.GRID_LANES);
}
return null;
}
}
export class GridTemplateMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly lines: CodeMirror.SyntaxNode[][]) {
}
}
// clang-format off
export class GridTemplateMatcher extends matcherBase(GridTemplateMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return cssMetadata().isGridAreaDefiningProperty(propertyName);
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): GridTemplateMatch|null {
if (node.name !== 'Declaration' || matching.hasUnresolvedSubstitutions(node)) {
return null;
}
const lines: CodeMirror.SyntaxNode[][] = [];
let curLine: CodeMirror.SyntaxNode[] = [];
// The following two states are designed to consume different cases of LineNames:
// 1. no LineNames in between StringLiterals;
// 2. one LineNames in between, which means the LineNames belongs to the current line;
// 3. two LineNames in between, which means the second LineNames starts a new line.
// `hasLeadingLineNames` tracks if the current row already starts with a LineNames and
// with no following StringLiteral yet, which means that the next StringLiteral should
// be appended to the same `curLine`, instead of creating a new line.
let hasLeadingLineNames = false;
// `needClosingLineNames` tracks if the current row can still consume an optional LineNames,
// which will decide if we should start a new line or not when a LineNames is encountered.
let needClosingLineNames = false;
/**
* Gather row definitions of [<line-names>? <string> <track-size>? <line-names>?], which
* be rendered into separate lines.
**/
function parseNodes(nodes: CodeMirror.SyntaxNode[], varParsingMode = false): void {
for (const curNode of nodes) {
if (matching.getMatch(curNode) instanceof BaseVariableMatch) {
const computedValueTree = tokenizeDeclaration('--property', matching.getComputedText(curNode));
if (!computedValueTree) {
continue;
}
const varNodes = ASTUtils.siblings(ASTUtils.declValue(computedValueTree.tree));
if (varNodes.length === 0) {
continue;
}
if ((varNodes[0].name === 'StringLiteral' && !hasLeadingLineNames) ||
(varNodes[0].name === 'BracketedValue' && !needClosingLineNames)) {
// The variable value either starts with a string, or with a line name that belongs to a new row;
// therefore we start a new line with the variable.
lines.push(curLine);
curLine = [curNode];
} else {
curLine.push(curNode);
}
// We parse computed nodes of this variable to correctly advance local states, but
// these computed nodes won't be added to the lines.
parseNodes(varNodes, true);
} else if (curNode.name === 'BinaryExpression') {
parseNodes(ASTUtils.siblings(curNode.firstChild));
} else if (curNode.name === 'StringLiteral') {
if (!varParsingMode) {
if (hasLeadingLineNames) {
curLine.push(curNode);
} else {
lines.push(curLine);
curLine = [curNode];
}
}
needClosingLineNames = true;
hasLeadingLineNames = false;
} else if (curNode.name === 'BracketedValue') {
if (!varParsingMode) {
if (needClosingLineNames) {
curLine.push(curNode);
} else {
lines.push(curLine);
curLine = [curNode];
}
}
hasLeadingLineNames = !needClosingLineNames;
needClosingLineNames = !needClosingLineNames;
} else if (!varParsingMode) {
curLine.push(curNode);
}
}
}
const valueNodes = ASTUtils.siblings(ASTUtils.declValue(node));
if (valueNodes.length === 0) {
return null;
}
parseNodes(valueNodes);
lines.push(curLine);
const valueText = matching.ast.textRange(valueNodes[0], valueNodes[valueNodes.length - 1]);
return new GridTemplateMatch(valueText, node, lines.filter(line => line.length > 0));
}
}
export class AnchorFunctionMatch implements Match {
constructor(readonly text: string, readonly node: CodeMirror.SyntaxNode, readonly functionName: string|null) {
}
}
// clang-format off
export class AnchorFunctionMatcher extends matcherBase(AnchorFunctionMatch) {
// clang-format on
anchorFunction(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): string|null {
if (node.name !== 'CallExpression') {
return null;
}
const calleeText = matching.ast.text(node.getChild('Callee'));
if (calleeText === 'anchor' || calleeText === 'anchor-size') {
return calleeText;
}
return null;
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): AnchorFunctionMatch|null {
if (node.name === 'VariableName') {
// Double-dashed anchor reference to be rendered with a link to its matching anchor.
let parent = node.parent;
if (parent?.name !== 'ArgList') {
return null;
}
parent = parent.parent;
if (!parent || !this.anchorFunction(parent, matching)) {
return null;
}
return new AnchorFunctionMatch(matching.ast.text(node), node, null);
}
const calleeText = this.anchorFunction(node, matching);
if (!calleeText) {
return null;
}
// Match if the anchor/anchor-size function implicitly references an anchor.
const args = ASTUtils.children(node.getChild('ArgList'));
if (calleeText === 'anchor' && args.length <= 2) {
return null;
}
if (args.find(arg => arg.name === 'VariableName')) {
// We have an explicit anchor reference, no need to render swatch.
return null;
}
return new AnchorFunctionMatch(matching.ast.text(node), node, calleeText);
}
}
/** For linking `position-anchor: --anchor-name`. **/
export class PositionAnchorMatch implements Match {
constructor(readonly text: string, readonly matching: BottomUpTreeMatching, readonly node: CodeMirror.SyntaxNode) {
}
}
// clang-format off
export class PositionAnchorMatcher extends matcherBase(PositionAnchorMatch) {
// clang-format on
override accepts(propertyName: string): boolean {
return propertyName === 'position-anchor';
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): PositionAnchorMatch|null {
if (node.name !== 'VariableName') {
return null;
}
const dashedIdentifier = matching.ast.text(node);
return new PositionAnchorMatch(dashedIdentifier, matching, node);
}
}
export class CSSWideKeywordMatch implements Match {
constructor(
readonly text: CSSWideKeyword, readonly node: CodeMirror.SyntaxNode, readonly property: CSSProperty,
readonly matchedStyles: CSSMatchedStyles) {
}
resolveProperty(): CSSValueSource|null {
return this.matchedStyles.resolveGlobalKeyword(this.property, this.text);
}
computedText?(): string|null {
return this.resolveProperty()?.value ?? null;
}
}
// clang-format off
export class CSSWideKeywordMatcher extends matcherBase(CSSWideKeywordMatch) {
// clang-format on
constructor(readonly property: CSSProperty, readonly matchedStyles: CSSMatchedStyles) {
super();
}
override matches(node: CodeMirror.SyntaxNode, matching: BottomUpTreeMatching): CSSWideKeywordMatch|null {
const parentNode = node.parent;
if (node.name !== 'ValueName' || parentNode?.name !== 'Declaration') {
return null;
}
if (Array.from(ASTUtils.stripComments(ASTUtils.siblings(ASTUtils.declValue(parentNode))))
.some(child => !ASTUtils.equals(child, node))) {
return null;
}
const text = matching.ast.text(node);
if (!CSSMetadata.isCSSWideKeyword(text)) {
return null;
}
return new CSSWide