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;
};