Files
webstudio/packages/css-data/src/parse-css-value.ts
T
Bogdan Chadkin adadbe3aa2 refactor: enable isolated declatations in css data (#4476)
Moved transition related stuff into builder, simplified mdn-data and
added missing types to exports, removed popularity index.
2024-11-30 16:47:26 +07:00

483 lines
12 KiB
TypeScript

import { colord } from "colord";
import * as csstree from "css-tree";
import { type CssNode, generate, lexer, List } from "css-tree";
import warnOnce from "warn-once";
import {
cssWideKeywords,
hyphenateProperty,
type ImageValue,
type KeywordValue,
type LayersValue,
type TupleValue,
type UnitValue,
type LayerValueItem,
type RgbValue,
type StyleProperty,
type StyleValue,
type Unit,
type VarValue,
type FunctionValue,
type TupleValueItem,
} from "@webstudio-is/css-engine";
import { keywordValues } from "./__generated__/keyword-values";
import { units } from "./__generated__/units";
export const cssTryParseValue = (input: string): undefined | CssNode => {
try {
const ast = csstree.parse(input, { context: "value" });
return ast;
} catch {
return;
}
};
const splitRepeated = (nodes: CssNode[]) => {
const lists: Array<CssNode[]> = [[]];
for (const node of nodes) {
if (node.type === "Operator" && node.value === ",") {
lists.push([]);
} else {
lists.at(-1)?.push(node);
}
}
return lists;
};
// Because csstree parser has bugs we use CSSStyleValue to validate css properties if available
// and fall back to csstree.
export const isValidDeclaration = (
property: string,
value: string
): boolean => {
const cssPropertyName = hyphenateProperty(property);
if (property.startsWith("--") || value.includes("var(")) {
return true;
}
// these properties have poor support natively and in csstree
// though rendered styles are merged as shorthand
// so validate artifically
if (cssPropertyName === "white-space-collapse") {
return keywordValues.whiteSpaceCollapse.includes(
value as (typeof keywordValues.whiteSpaceCollapse)[0]
);
}
if (cssPropertyName === "text-wrap-mode") {
return keywordValues.textWrapMode.includes(
value as (typeof keywordValues.textWrapMode)[0]
);
}
if (cssPropertyName === "text-wrap-style") {
return keywordValues.textWrapStyle.includes(
value as (typeof keywordValues.textWrapStyle)[0]
);
}
if (cssPropertyName === "transition-behavior") {
return true;
}
// @todo remove after csstree fixes
// - https://github.com/csstree/csstree/issues/246
// - https://github.com/csstree/csstree/issues/164
if (typeof CSSStyleValue !== "undefined") {
try {
CSSStyleValue.parse(cssPropertyName, value);
return true;
} catch {
return false;
}
}
const ast = cssTryParseValue(value);
if (ast == null) {
return false;
}
// scale css-proeprty accepts both number and percentage.
// The syntax from MDN is incorrect and should be updated.
// Here is a PR that fixes the same, but it is not merged yet.
// https://github.com/mdn/data/pull/746
if (cssPropertyName === "scale") {
const syntax = "none | [ <number> | <percentage> ]{1,3}";
return lexer.match(syntax, ast).matched !== null;
}
const matchResult = csstree.lexer.matchProperty(cssPropertyName, ast);
// allow to parse unknown properties as unparsed
if (matchResult.error?.message.includes("Unknown property")) {
return true;
}
return matchResult.matched != null;
};
const repeatedProps = new Set<StyleProperty>([
"backgroundAttachment",
"backgroundClip",
"backgroundBlendMode",
"backgroundOrigin",
"backgroundPositionX",
"backgroundPositionY",
"backgroundRepeat",
"backgroundSize",
"backgroundImage",
"transitionProperty",
"transitionDuration",
"transitionDelay",
"transitionTimingFunction",
"transitionBehavior",
"boxShadow",
"textShadow",
]);
const tupleProps = new Set<StyleProperty>([
"boxShadow",
"textShadow",
"scale",
"translate",
"rotate",
"transform",
"filter",
"backdropFilter",
"transformOrigin",
"perspectiveOrigin",
]);
const availableUnits = new Set<string>(Object.values(units).flat());
const parseColor = (colorString: string): undefined | RgbValue => {
const color = colord(colorString);
if (color.isValid()) {
const rgb = color.toRgb();
return {
type: "rgb",
alpha: rgb.a,
r: rgb.r,
g: rgb.g,
b: rgb.b,
};
}
};
const parseLiteral = (
node: undefined | null | CssNode,
keywords?: readonly string[]
):
| undefined
| UnitValue
| KeywordValue
| ImageValue
| RgbValue
| VarValue
| FunctionValue => {
if (node?.type === "Number") {
return {
type: "unit",
unit: "number",
value: Number(node.value),
};
}
if (node?.type === "Dimension" && availableUnits.has(node.unit)) {
return {
type: "unit",
unit: node.unit as Unit,
value: Number(node.value),
};
}
if (node?.type === "Percentage") {
return {
type: "unit",
unit: "%",
value: Number(node.value),
};
}
if (node?.type === "Identifier") {
const name = node.name.toLowerCase();
if (keywords?.map((keyword) => keyword.toLowerCase()).includes(name)) {
return {
type: "keyword",
value: name,
};
}
}
if (node?.type === "Url") {
return {
type: "image",
value: {
type: "url",
url: node.value,
},
};
}
if (node?.type === "Hash") {
const color = parseColor(`#${node.value}`);
if (color) {
return color;
}
}
if (node?.type === "Function") {
// <color-function>
if (
node.name === "hsl" ||
node.name === "hsla" ||
node.name === "rgb" ||
node.name === "rgba"
) {
const color = parseColor(generate(node));
if (color) {
return color;
}
}
if (node.name === "var") {
const [name, _comma, ...fallback] = node.children;
const fallbackString = generate({
type: "Value",
children: new List<CssNode>().fromArray(fallback),
}).trim();
if (name.type === "Identifier") {
const value: VarValue = {
type: "var",
value: name.name.slice("--".length),
};
if (fallback.length > 0) {
value.fallback = { type: "unparsed", value: fallbackString };
}
return value;
}
}
// functions with comma-separated arguments
if (
// <transform-function>
// 2d
node.name === "matrix" ||
node.name === "translate" ||
node.name === "translateX" ||
node.name === "translateY" ||
node.name === "scale" ||
node.name === "scaleX" ||
node.name === "scaleY" ||
node.name === "rotate" ||
node.name === "skew" ||
node.name === "skewX" ||
node.name === "skewY" ||
// 3d
node.name === "matrix3d" ||
node.name === "translate3d" ||
node.name === "translateZ" ||
node.name === "scale3d" ||
node.name === "scaleZ" ||
node.name === "rotate3d" ||
node.name === "rotateX" ||
node.name === "rotateY" ||
node.name === "rotateZ" ||
node.name === "perspective" ||
// <easing-function>
node.name === "linear" ||
node.name === "cubic-bezier" ||
node.name === "steps"
) {
const args: LayersValue = { type: "layers", value: [] };
for (const arg of node.children) {
const matchedValue = parseLiteral(arg);
if (matchedValue) {
args.value.push(matchedValue as LayerValueItem);
}
if (arg.type === "Identifier") {
args.value.push({ type: "keyword", value: arg.name });
}
}
return { type: "function", args, name: node.name };
}
// functions with space separated arguments
if (
// <filter-function>
node.name === "blur" ||
node.name === "brightness" ||
node.name === "contrast" ||
node.name === "drop-shadow" ||
node.name === "grayscale" ||
node.name === "hue-rotate" ||
node.name === "invert" ||
node.name === "opacity" ||
node.name === "sepia" ||
node.name === "saturate"
) {
const args: TupleValue = { type: "tuple", value: [] };
for (const arg of node.children) {
const matchedValue = parseLiteral(arg);
if (matchedValue) {
args.value.push(matchedValue as TupleValueItem);
}
if (arg.type === "Identifier") {
args.value.push({ type: "keyword", value: arg.name });
}
}
return { type: "function", args, name: node.name };
}
}
};
export const parseCssValue = (
property: StyleProperty, // Handles only long-hand values.
input: string,
topLevel = true
): StyleValue => {
const potentialKeyword = input.toLowerCase().trim();
if (cssWideKeywords.has(potentialKeyword)) {
return { type: "keyword", value: potentialKeyword };
}
if (property === "transitionProperty" && potentialKeyword === "none") {
if (topLevel) {
return { type: "keyword", value: potentialKeyword };
} else {
// none is not valid layer keyword
return { type: "unparsed", value: potentialKeyword };
}
}
const invalidValue = {
type: "invalid",
value: input,
} as const;
if (input.length === 0) {
return invalidValue;
}
if (isValidDeclaration(property, input) === false) {
return invalidValue;
}
const ast = cssTryParseValue(input);
if (ast == null) {
warnOnce(
true,
`Can't parse css property "${property}" with value "${input}"`
);
return invalidValue;
}
const nodes = "children" in ast ? (ast.children?.toArray() ?? []) : [ast];
// support only following types in custom properties
if (property.startsWith("--")) {
if (nodes.length === 1) {
const parsedValue = parseLiteral(nodes[0]);
if (
parsedValue?.type === "var" ||
parsedValue?.type === "unit" ||
parsedValue?.type === "rgb"
) {
return parsedValue;
}
}
return { type: "unparsed", value: input };
}
// parse single var() without wrapping with layers or tuples
// which can possibly get nested when variables are computed
if (
nodes.length === 1 &&
nodes[0].type === "Function" &&
nodes[0].name === "var"
) {
return parseLiteral(nodes[0]) ?? invalidValue;
}
// prevent infinite splitting into layers for items
if (repeatedProps.has(property) && topLevel) {
let invalid = false;
const layersValue: StyleValue = {
type: "layers",
value: splitRepeated(nodes).map((nodes) => {
const value = csstree.generate({
type: "Value",
children: new csstree.List<CssNode>().fromArray(nodes),
});
const parsed = parseCssValue(property, value, false) as LayerValueItem;
if (parsed.type === "invalid") {
invalid = true;
}
return parsed;
}),
};
// at least one layer is invalid then whole value is invalid
if (invalid) {
return invalidValue;
}
return layersValue;
}
// csstree does not support transition-behavior
// so check keywords manually
if (property === "transitionBehavior") {
const node = ast.type === "Value" ? ast.children.first : ast;
const keyword = parseLiteral(node, keywordValues[property]);
if (keyword?.type === "keyword") {
return keyword;
}
return invalidValue;
}
if (property === "fontFamily") {
return {
type: "fontFamily",
value: splitRepeated(nodes).map((nodes) => {
// unquote values
if (nodes.length === 1 && nodes[0].type === "String") {
return nodes[0].value;
}
return generate({
type: "Value",
children: new List<CssNode>().fromArray(nodes),
});
}),
};
}
// Probably a tuple like background-size or box-shadow
if (
ast.type === "Value" &&
(ast.children.size === 2 || tupleProps.has(property))
) {
const tuple: TupleValue = {
type: "tuple",
value: [],
};
for (const node of ast.children) {
// output any values with unhandled operators like slash or comma as unparsed
if (node.type === "Operator") {
return { type: "unparsed", value: input };
}
const matchedValue = parseLiteral(node, keywordValues[property as never]);
if (matchedValue) {
tuple.value.push(matchedValue as never);
} else {
tuple.value.push({ type: "unparsed", value: generate(node) });
}
}
return tuple;
}
if (ast.type === "Value" && ast.children.size === 1) {
// Try extract units from 1st children
const first = ast.children.first;
const matchedValue = parseLiteral(first, keywordValues[property as never]);
if (matchedValue) {
return matchedValue;
}
}
return {
type: "unparsed",
value: input,
};
};