diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 74b235aa2d1d..403b3f50a494 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -280,6 +280,27 @@ test("generate style attribute as local styles", () => { ); }); +test("generate nested css math functions as unparsed local styles", () => { + const value = + "clamp(1rem, calc(1rem + (2rem - 1rem) * ((100vw - 20rem) / (80rem - 20rem))), 2rem)"; + const fragment = generateFragmentFromHtml(` +
One clamp div
+ `); + + expect(fragment.styles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: "fontSize", + value: { + type: "unparsed", + value: + "clamp(1rem,calc(1rem + (2rem - 1rem)*((100vw - 20rem)/(80rem - 20rem))),2rem)", + }, + }), + ]) + ); +}); + test("script as html embed", () => { expect(generateFragmentFromHtml(``)).toEqual( renderTemplate( diff --git a/packages/css-data/src/parse-css-value.test.ts b/packages/css-data/src/parse-css-value.test.ts index 0252be04062c..3b875822b12f 100644 --- a/packages/css-data/src/parse-css-value.test.ts +++ b/packages/css-data/src/parse-css-value.test.ts @@ -73,11 +73,44 @@ describe("Parse CSS value", () => { }); }); + test("Nested CSS math values", () => { + const value = + "clamp(1rem, calc(1rem + (2rem - 1rem) * ((100vw - 20rem) / (80rem - 20rem))), 2rem)"; + + expect(parseCssValue("font-size", value)).toEqual({ + type: "unparsed", + value, + }); + }); + + test("CSS numeric functions", () => { + expect(parseCssValue("width", "round(10px, 1px)")).toEqual({ + type: "unparsed", + value: "round(10px, 1px)", + }); + expect(parseCssValue("width", "abs(-10px)")).toEqual({ + type: "unparsed", + value: "abs(-10px)", + }); + expect(parseCssValue("width", "calc(pi * 1px)")).toEqual({ + type: "unparsed", + value: "calc(pi * 1px)", + }); + }); + test("Invalid function values", () => { expect(parseCssValue("width", "blur(4)")).toEqual({ type: "invalid", value: "blur(4)", }); + expect(parseCssValue("font-size", "clamp(foo)")).toEqual({ + type: "invalid", + value: "clamp(foo)", + }); + expect(parseCssValue("color", "abs(-10px)")).toEqual({ + type: "invalid", + value: "abs(-10px)", + }); }); }); diff --git a/packages/css-data/src/parse-css-value.ts b/packages/css-data/src/parse-css-value.ts index 3d0a6ff07d60..86ffa8005832 100644 --- a/packages/css-data/src/parse-css-value.ts +++ b/packages/css-data/src/parse-css-value.ts @@ -1,5 +1,6 @@ import { type CssNode, + definitionSyntax, type FunctionNode, generate, lexer, @@ -54,6 +55,91 @@ const splitRepeated = (nodes: CssNode[]) => { return lists; }; +const cssNumericFunctionNames = new Set([ + "calc", + "min", + "max", + "clamp", + "round", + "mod", + "rem", + "sin", + "cos", + "tan", + "asin", + "acos", + "atan", + "atan2", + "pow", + "sqrt", + "hypot", + "log", + "exp", + "abs", + "sign", +]); + +const cssMathConstants = new Set(["e", "pi", "infinity", "-infinity", "nan"]); + +const cssNumericTypeNames = new Set([ + "length", + "length-percentage", + "percentage", + "number", + "integer", + "angle", + "time", + "frequency", + "resolution", + "flex", + "alpha-value", +]); + +const canFallbackToCssMath = (ast: CssNode, syntax: string | undefined) => { + if (syntax === undefined) { + return false; + } + + let hasCssNumericType = false; + try { + definitionSyntax.walk(definitionSyntax.parse(syntax), (node) => { + if ( + node.type === "Type" && + "name" in node && + cssNumericTypeNames.has(node.name) + ) { + hasCssNumericType = true; + } + }); + } catch { + return false; + } + if (hasCssNumericType === false) { + return false; + } + + let hasCssNumericFunction = false; + let hasUnknownIdentifier = false; + walk(ast, (node) => { + if (node.type === "Function" && cssNumericFunctionNames.has(node.name)) { + hasCssNumericFunction = true; + } + if ( + node.type === "Identifier" && + cssMathConstants.has(node.name.toLowerCase()) === false + ) { + hasUnknownIdentifier = true; + } + }); + return hasCssNumericFunction && hasUnknownIdentifier === false; +}; + +const getSyntaxMatchErrorSyntax = (error: Error | null | undefined) => { + if (error != null && "syntax" in error && typeof error.syntax === "string") { + return error.syntax; + } +}; + // Because csstree parser has bugs we use CSSStyleValue to validate css properties if available // and fall back to csstree. export const isValidDeclaration = ( @@ -109,6 +195,10 @@ export const isValidDeclaration = ( // @todo remove after csstree fixes // - https://github.com/csstree/csstree/issues/246 // - https://github.com/csstree/csstree/issues/164 + if (typeof CSS !== "undefined" && CSS.supports(property, value)) { + return true; + } + if (typeof CSSStyleValue !== "undefined") { try { CSSStyleValue.parse(property, value); @@ -143,6 +233,17 @@ export const isValidDeclaration = ( return true; } + // css-tree does not fully validate modern CSS math with nested calc() + // operators, for example `font-size: clamp(... calc(... / ...) ...)`. + // Browser-valid values should be preserved as unparsed instead of stored as + // invalid values, which are intended for transient editor state. + if ( + matchResult.matched == null && + canFallbackToCssMath(ast, getSyntaxMatchErrorSyntax(matchResult.error)) + ) { + return true; + } + return matchResult.matched != null; };