From ac4d14a338882ad2c48799831f9b2b219b234631 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sat, 22 Feb 2025 17:51:18 +0700 Subject: [PATCH] refactor: output hyphenated properties from css parser We are going to switch to hyphenated properties in styles. Here refactored css parser to output hyphenated property instead of camel case and added camelCaseProperty utility which does the opposite of hyphenateProperty. --- .../sections/advanced/parse-style-input.ts | 40 +- .../style-panel/shared/css-fragment.tsx | 13 +- .../copy-paste/plugin-webflow/styles.ts | 10 +- .../app/shared/style-object-model.test.tsx | 4 +- .../chains/operations/edit-styles.server.ts | 13 +- packages/css-data/bin/css-to-ws.ts | 10 +- packages/css-data/bin/html.css.ts | 7 +- packages/css-data/bin/mdn-data.ts | 46 +-- .../css-data/src/__generated__/properties.ts | 2 +- packages/css-data/src/parse-css.test.ts | 72 ++-- packages/css-data/src/parse-css.ts | 85 +++-- packages/css-data/src/shorthands.ts | 6 +- .../src/tailwind-parser/parse.test.ts | 247 ++++--------- .../css-data/src/tailwind-parser/parse.ts | 8 +- .../css-engine/src/__generated__/types.ts | 341 +++++++++++++++++- packages/css-engine/src/index.ts | 5 +- packages/css-engine/src/schema.ts | 7 +- packages/jsx-utils/src/tw-to-webstudio.ts | 19 +- packages/sdk/scripts/normalize.css.ts | 4 +- packages/template/src/css.ts | 4 +- 20 files changed, 618 insertions(+), 325 deletions(-) diff --git a/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.ts b/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.ts index 01cabcdcf54a..f58cead297b9 100644 --- a/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.ts +++ b/apps/builder/app/builder/features/style-panel/sections/advanced/parse-style-input.ts @@ -1,12 +1,16 @@ import { + type ParsedStyleDecl, properties, parseCss, - type ParsedStyleDecl, + camelCaseProperty, } from "@webstudio-is/css-data"; -import { type StyleProperty } from "@webstudio-is/css-engine"; -import { camelCase } from "change-case"; +import type { CssProperty, StyleProperty } from "@webstudio-is/css-engine"; import { lexer } from "css-tree"; +type StyleDecl = Omit & { + property: StyleProperty; +}; + /** * Does several attempts to parse: * - Custom property "--foo" @@ -16,7 +20,7 @@ import { lexer } from "css-tree"; * - Property and value: color: red * - Multiple properties: color: red; background: blue */ -export const parseStyleInput = (css: string): Array => { +export const parseStyleInput = (css: string): Array => { css = css.trim(); // Is it a custom property "--foo"? if (css.startsWith("--") && lexer.match("", css).matched) { @@ -30,12 +34,11 @@ export const parseStyleInput = (css: string): Array => { } // Is it a known regular property? - const camelCasedProperty = camelCase(css); - if (camelCasedProperty in properties) { + if (camelCaseProperty(css as CssProperty) in properties) { return [ { selector: "selector", - property: css as StyleProperty, + property: camelCaseProperty(css as CssProperty), value: { type: "unset", value: "" }, }, ]; @@ -52,20 +55,29 @@ export const parseStyleInput = (css: string): Array => { ]; } - const styles = parseCss(`selector{${css}}`); + const hyphenatedStyles = parseCss(`selector{${css}}`); + const newStyles: StyleDecl[] = []; - for (const style of styles) { + for (const { property, ...styleDecl } of hyphenatedStyles) { // somethingunknown: red; -> --somethingunknown: red; if ( // Note: currently in tests it returns unparsed, but in the client it returns invalid, // because we use native APIs when available in parseCss. - style.value.type === "invalid" || - (style.value.type === "unparsed" && - style.property.startsWith("--") === false) + styleDecl.value.type === "invalid" || + (styleDecl.value.type === "unparsed" && + property.startsWith("--") === false) ) { - style.property = `--${style.property}`; + newStyles.push({ + ...styleDecl, + property: `--${property}`, + }); + } else { + newStyles.push({ + ...styleDecl, + property: camelCaseProperty(property), + }); } } - return styles; + return newStyles; }; diff --git a/apps/builder/app/builder/features/style-panel/shared/css-fragment.tsx b/apps/builder/app/builder/features/style-panel/shared/css-fragment.tsx index a0356ed4a7a4..85b1bd87df5a 100644 --- a/apps/builder/app/builder/features/style-panel/shared/css-fragment.tsx +++ b/apps/builder/app/builder/features/style-panel/shared/css-fragment.tsx @@ -7,8 +7,9 @@ import { completionKeymap, type CompletionSource, } from "@codemirror/autocomplete"; -import { parseCss } from "@webstudio-is/css-data"; +import { camelCaseProperty, parseCss } from "@webstudio-is/css-data"; import { css as style } from "@webstudio-is/design-system"; +import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine"; import { EditorContent, EditorDialog, @@ -18,7 +19,10 @@ import { } from "~/builder/shared/code-editor-base"; import { $availableVariables } from "./model"; -export const parseCssFragment = (css: string, fallbacks: string[]) => { +export const parseCssFragment = ( + css: string, + fallbacks: string[] +): Map => { let parsed = parseCss(`.styles{${css}}`); if (parsed.length === 0) { for (const fallbackProperty of fallbacks) { @@ -30,7 +34,10 @@ export const parseCssFragment = (css: string, fallbacks: string[]) => { } } return new Map( - parsed.map((styleDecl) => [styleDecl.property, styleDecl.value]) + parsed.map((styleDecl) => [ + camelCaseProperty(styleDecl.property), + styleDecl.value, + ]) ); }; diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/styles.ts b/apps/builder/app/shared/copy-paste/plugin-webflow/styles.ts index 94cac5a8dbbb..faff2614748e 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/styles.ts +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/styles.ts @@ -7,11 +7,11 @@ import type { WfAsset, WfElementNode, WfNode, WfStyle } from "./schema"; import { nanoid } from "nanoid"; import { $styleSources } from "~/shared/nano-states"; import { + camelCaseProperty, parseCss, pseudoElements, type ParsedStyleDecl, } from "@webstudio-is/css-data"; -import { kebabCase } from "change-case"; import { equalMedia, hyphenateProperty } from "@webstudio-is/css-engine"; import type { WfStylePresets } from "./style-presets-overrides"; import { builderApi } from "~/shared/builder-api"; @@ -103,7 +103,7 @@ const replaceAtImages = ( }; const processStyles = (parsedStyles: ParsedStyleDecl[]) => { - const styles = new Map(); + const styles = new Map(); for (const parsedStyleDecl of parsedStyles) { const { breakpoint, selector, state, property } = parsedStyleDecl; const key = `${breakpoint}:${selector}:${state}:${property}`; @@ -113,7 +113,7 @@ const processStyles = (parsedStyles: ParsedStyleDecl[]) => { const { breakpoint, selector, state, property } = parsedStyleDecl; const key = `${breakpoint}:${selector}:${state}:${property}`; styles.set(key, parsedStyleDecl); - if (property === "backgroundClip") { + if (property === "background-clip") { const colorKey = `${breakpoint}:${selector}:${state}:color`; styles.delete(colorKey); styles.set(colorKey, { @@ -197,12 +197,12 @@ const addNodeStyles = ({ fragment.styles.push({ styleSourceId, breakpointId: breakpoint.id, - property: style.property, + property: camelCaseProperty(style.property), value: style.value, state: style.state, }); if (style.value.type === "invalid") { - const error = `Invalid style value: Local "${kebabCase(style.property)}: ${style.value.value}"`; + const error = `Invalid style value: Local "${hyphenateProperty(style.property)}: ${style.value.value}"`; toast.error(error); console.error(error); } diff --git a/apps/builder/app/shared/style-object-model.test.tsx b/apps/builder/app/shared/style-object-model.test.tsx index 108a24f27b9a..9a2cb715ad81 100644 --- a/apps/builder/app/shared/style-object-model.test.tsx +++ b/apps/builder/app/shared/style-object-model.test.tsx @@ -10,7 +10,7 @@ import { getStyleDeclKey, } from "@webstudio-is/sdk"; import { $, renderData } from "@webstudio-is/template"; -import { parseCss } from "@webstudio-is/css-data"; +import { camelCaseProperty, parseCss } from "@webstudio-is/css-data"; import type { StyleValue } from "@webstudio-is/css-engine"; import { type StyleObjectModel, @@ -54,7 +54,7 @@ const createModel = ({ styleSourceId: selector, breakpointId: breakpoint ?? "base", state, - property, + property: camelCaseProperty(property), value, }; styles.set(getStyleDeclKey(styleDecl), styleDecl); diff --git a/packages/ai/src/chains/operations/edit-styles.server.ts b/packages/ai/src/chains/operations/edit-styles.server.ts index c717446e930b..a5104e781a47 100644 --- a/packages/ai/src/chains/operations/edit-styles.server.ts +++ b/packages/ai/src/chains/operations/edit-styles.server.ts @@ -1,4 +1,7 @@ -import { parseTailwindToWebstudio } from "@webstudio-is/css-data"; +import { + camelCaseProperty, + parseTailwindToWebstudio, +} from "@webstudio-is/css-data"; import type { aiOperation, wsOperation } from "./edit-styles"; export { name } from "./edit-styles"; @@ -11,10 +14,14 @@ export const aiOperationToWs = async ( if (operation.className === "") { throw new Error(`Operation ${operation.operation} className is empty`); } - const styles = await parseTailwindToWebstudio(operation.className); + const hyphenatedStyles = await parseTailwindToWebstudio(operation.className); + const newStyles = hyphenatedStyles.map(({ property, ...styleDecl }) => ({ + ...styleDecl, + property: camelCaseProperty(property), + })); return { operation: "applyStyles", instanceIds: operation.wsIds, - styles: styles, + styles: newStyles, }; }; diff --git a/packages/css-data/bin/css-to-ws.ts b/packages/css-data/bin/css-to-ws.ts index f6844b2b4ca8..27c4d6a48b69 100755 --- a/packages/css-data/bin/css-to-ws.ts +++ b/packages/css-data/bin/css-to-ws.ts @@ -2,7 +2,7 @@ import { parseArgs, type ParseArgsConfig } from "node:util"; import * as path from "node:path"; import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { parseCss } from "../src/parse-css"; +import { camelCaseProperty, parseCss } from "../src/parse-css"; const cliOptions = { allowPositionals: true, @@ -34,7 +34,13 @@ const objectGroupBy = (list: Item[], by: (item: Item) => string) => { }; const css = readFileSync(sourcePath, "utf8"); -const parsed = parseCss(css); +const parsed = parseCss(css).map(({ property, ...styleDecl }) => ({ + selector: styleDecl.selector, + breakpoint: styleDecl.breakpoint, + state: styleDecl.state, + property: camelCaseProperty(property), + value: styleDecl.value, +})); const records = objectGroupBy(parsed, (item) => item.selector); mkdirSync(path.dirname(destinationPath), { recursive: true }); const code = `/* eslint-disable */ diff --git a/packages/css-data/bin/html.css.ts b/packages/css-data/bin/html.css.ts index b2c3c148f7a1..67d55edf84dd 100644 --- a/packages/css-data/bin/html.css.ts +++ b/packages/css-data/bin/html.css.ts @@ -1,12 +1,15 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import type { StyleValue } from "@webstudio-is/css-engine"; -import { parseCss } from "../src/parse-css"; +import { camelCaseProperty, parseCss } from "../src/parse-css"; const css = readFileSync("./src/html.css", "utf8"); const parsed = parseCss(css); const result: [string, StyleValue][] = []; for (const styleDecl of parsed) { - result.push([`${styleDecl.selector}:${styleDecl.property}`, styleDecl.value]); + result.push([ + `${styleDecl.selector}:${camelCaseProperty(styleDecl.property)}`, + styleDecl.value, + ]); } let code = ""; code += `import type { HtmlTags } from "html-tags";\n`; diff --git a/packages/css-data/bin/mdn-data.ts b/packages/css-data/bin/mdn-data.ts index a80aa07ee5e2..ca7445e6581a 100755 --- a/packages/css-data/bin/mdn-data.ts +++ b/packages/css-data/bin/mdn-data.ts @@ -7,28 +7,18 @@ import properties from "mdn-data/css/properties.json"; import syntaxes from "mdn-data/css/syntaxes.json"; import selectors from "mdn-data/css/selectors.json"; import data from "css-tree/dist/data"; -import { camelCase } from "change-case"; -import type { - KeywordValue, - StyleValue, - Unit, - UnitValue, - UnparsedValue, - FontFamilyValue, +import { + type KeywordValue, + type StyleValue, + type Unit, + type UnitValue, + type UnparsedValue, + type FontFamilyValue, + hyphenateProperty, + type CssProperty, } from "@webstudio-is/css-engine"; import * as customData from "../src/custom-data"; - -/** - * Store prefixed properties without change - * and convert to camel case only unprefixed properties - * @todo stop converting to camel case and use hyphenated format - */ -const normalizePropertyName = (property: string) => { - if (property.startsWith("-")) { - return property; - } - return camelCase(property); -}; +import { camelCaseProperty } from "../src/parse-css"; const units: Record> = { number: [], @@ -233,7 +223,7 @@ const walkSyntax = ( walk(parsed); }; -type FilteredProperties = { [property in Property]: Value }; +type FilteredProperties = { [property: string]: Value }; const experimentalProperties = [ "appearance", @@ -299,7 +289,7 @@ const propertiesData = { ...customData.propertiesData, }; -let property: Property; +let property: string; for (property in filteredProperties) { const config = filteredProperties[property]; const unitGroups = new Set(); @@ -326,7 +316,7 @@ for (property in filteredProperties) { ); } - propertiesData[normalizePropertyName(property)] = { + propertiesData[camelCaseProperty(property as CssProperty)] = { unitGroups: Array.from(unitGroups), inherited: config.inherited, initial: parseInitialValue(property, config.initial, unitGroups), @@ -367,7 +357,7 @@ const keywordValues = (() => { const result = { ...customData.keywordValues }; for (const property in filteredProperties) { - const key = normalizePropertyName(property); + const key = camelCaseProperty(property as CssProperty); // prevent merging with custom keywords if (result[key]) { continue; @@ -416,10 +406,14 @@ writeToFile("pseudo-elements.ts", "pseudoElements", pseudoElements); let types = ""; -const propertyLiterals = Object.keys(propertiesData).map((property) => +const camelCasedProperties = Object.keys(propertiesData).map((property) => JSON.stringify(property) ); -types += `export type Property = ${propertyLiterals.join(" | ")};\n\n`; +types += `export type CamelCasedProperty = ${camelCasedProperties.join(" | ")};\n\n`; +const hyphenatedProperties = Object.keys(propertiesData).map((property) => + JSON.stringify(hyphenateProperty(property)) +); +types += `export type HyphenatedProperty = ${hyphenatedProperties.join(" | ")};\n\n`; const unitLiterals = Object.values(units) .flat() diff --git a/packages/css-data/src/__generated__/properties.ts b/packages/css-data/src/__generated__/properties.ts index 8418e32cee45..68e3f33ba324 100644 --- a/packages/css-data/src/__generated__/properties.ts +++ b/packages/css-data/src/__generated__/properties.ts @@ -45,7 +45,7 @@ export const properties = { value: "--scroll-timeline", }, mdnUrl: - "https://developer.mozilla.org/en-US/docs/Web/CSS/view-timeline-name", + "https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-timeline-name", }, "-webkit-line-clamp": { unitGroups: ["number"], diff --git a/packages/css-data/src/parse-css.test.ts b/packages/css-data/src/parse-css.test.ts index 6575bc0a7e20..edd206b34fba 100644 --- a/packages/css-data/src/parse-css.test.ts +++ b/packages/css-data/src/parse-css.test.ts @@ -6,7 +6,7 @@ describe("Parse CSS", () => { expect(parseCss(`.test { background-color: red }`)).toEqual([ { selector: ".test", - property: "backgroundColor", + property: "background-color", value: { type: "keyword", value: "red" }, }, ]); @@ -31,7 +31,7 @@ describe("Parse CSS", () => { expect(parseCss(css)).toEqual([ { selector: ".test", - property: "backgroundImage", + property: "background-image", value: { type: "layers", value: [ @@ -46,7 +46,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundPositionX", + property: "background-position-x", value: { type: "layers", value: [ @@ -57,7 +57,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundPositionY", + property: "background-position-y", value: { type: "layers", value: [ @@ -68,7 +68,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundSize", + property: "background-size", value: { type: "layers", value: [ @@ -91,7 +91,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundRepeat", + property: "background-repeat", value: { type: "layers", value: [ @@ -102,7 +102,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundAttachment", + property: "background-attachment", value: { type: "layers", value: [ @@ -113,7 +113,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundOrigin", + property: "background-origin", value: { type: "layers", value: [ @@ -124,7 +124,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundClip", + property: "background-clip", value: { type: "layers", value: [ @@ -135,7 +135,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundColor", + property: "background-color", value: { alpha: 1, b: 252, g: 255, r: 235, type: "rgb" }, }, ]); @@ -150,7 +150,7 @@ describe("Parse CSS", () => { expect(parseCss(css)).toEqual([ { selector: ".test", - property: "backgroundImage", + property: "background-image", value: { type: "layers", value: [{ type: "keyword", value: "none" }], @@ -158,7 +158,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundPositionX", + property: "background-position-x", value: { type: "layers", value: [{ type: "unit", unit: "px", value: 0 }], @@ -166,7 +166,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundPositionY", + property: "background-position-y", value: { type: "layers", value: [{ type: "unit", unit: "px", value: 0 }], @@ -174,7 +174,7 @@ describe("Parse CSS", () => { }, { selector: ".test", - property: "backgroundSize", + property: "background-size", value: { type: "layers", value: [{ type: "keyword", value: "auto" }], @@ -305,27 +305,27 @@ describe("Parse CSS", () => { expect(parseCss(css)).toEqual([ { selector: "h1", - property: "marginBottom", + property: "margin-bottom", value: { type: "unit", unit: "px", value: 10 }, }, { selector: "h1", - property: "fontSize", + property: "font-size", value: { type: "unit", unit: "px", value: 38 }, }, { selector: "h1", - property: "fontWeight", + property: "font-weight", value: { type: "keyword", value: "bold" }, }, { selector: "h1", - property: "marginTop", + property: "margin-top", value: { type: "unit", unit: "px", value: 20 }, }, { selector: "h1", - property: "lineHeight", + property: "line-height", value: { type: "unit", unit: "px", value: 44 }, }, ]); @@ -335,62 +335,62 @@ describe("Parse CSS", () => { expect(parseCss(`a { border: 1px solid red }`)).toEqual([ { selector: "a", - property: "borderTopWidth", + property: "border-top-width", value: { type: "unit", unit: "px", value: 1 }, }, { selector: "a", - property: "borderRightWidth", + property: "border-right-width", value: { type: "unit", unit: "px", value: 1 }, }, { selector: "a", - property: "borderBottomWidth", + property: "border-bottom-width", value: { type: "unit", unit: "px", value: 1 }, }, { selector: "a", - property: "borderLeftWidth", + property: "border-left-width", value: { type: "unit", unit: "px", value: 1 }, }, { selector: "a", - property: "borderTopStyle", + property: "border-top-style", value: { type: "keyword", value: "solid" }, }, { selector: "a", - property: "borderRightStyle", + property: "border-right-style", value: { type: "keyword", value: "solid" }, }, { selector: "a", - property: "borderBottomStyle", + property: "border-bottom-style", value: { type: "keyword", value: "solid" }, }, { selector: "a", - property: "borderLeftStyle", + property: "border-left-style", value: { type: "keyword", value: "solid" }, }, { selector: "a", - property: "borderTopColor", + property: "border-top-color", value: { type: "keyword", value: "red" }, }, { selector: "a", - property: "borderRightColor", + property: "border-right-color", value: { type: "keyword", value: "red" }, }, { selector: "a", - property: "borderBottomColor", + property: "border-bottom-color", value: { type: "keyword", value: "red" }, }, { selector: "a", - property: "borderLeftColor", + property: "border-left-color", value: { type: "keyword", value: "red" }, }, ]); @@ -424,7 +424,7 @@ describe("Parse CSS", () => { }, { selector: "a", - property: "backgroundColor", + property: "background-color", value: { type: "var", value: "color", @@ -443,7 +443,7 @@ describe("Parse CSS", () => { }, { selector: "a", - property: "backgroundColor", + property: "background-color", value: { type: "keyword", value: "red" }, }, ]); @@ -516,17 +516,17 @@ test("parse font-smooth properties", () => { ).toEqual([ { selector: "a", - property: "WebkitFontSmoothing", + property: "-webkit-font-smoothing", value: { type: "keyword", value: "auto" }, }, { selector: "b", - property: "WebkitFontSmoothing", + property: "-webkit-font-smoothing", value: { type: "keyword", value: "auto" }, }, { selector: "c", - property: "MozOsxFontSmoothing", + property: "-moz-osx-font-smoothing", value: { type: "keyword", value: "auto" }, }, ]); diff --git a/packages/css-data/src/parse-css.ts b/packages/css-data/src/parse-css.ts index 0796c52d024f..45806fbab954 100644 --- a/packages/css-data/src/parse-css.ts +++ b/packages/css-data/src/parse-css.ts @@ -1,6 +1,10 @@ import { camelCase } from "change-case"; import * as csstree from "css-tree"; -import { StyleValue, type StyleProperty } from "@webstudio-is/css-engine"; +import { + StyleValue, + type CssProperty, + type StyleProperty, +} from "@webstudio-is/css-engine"; import { parseCssValue as parseCssValueLonghand } from "./parse-css-value"; import { expandShorthands } from "./shorthands"; @@ -8,34 +12,10 @@ export type ParsedStyleDecl = { breakpoint?: string; selector: string; state?: string; - property: StyleProperty; + property: CssProperty; value: StyleValue; }; -/** - * Store prefixed properties without change - * and convert to camel case only unprefixed properties - * @todo stop converting to camel case and use hyphenated format - */ -const normalizePropertyName = (property: string) => { - // these are manually added with pascal case - // convert unprefixed used by webflow version into prefixed one - if (property === "-webkit-font-smoothing" || property === "font-smoothing") { - return "WebkitFontSmoothing"; - } - if (property === "-moz-osx-font-smoothing") { - return "MozOsxFontSmoothing"; - } - // webflow use unprefixed version - if (property === "tap-highlight-color") { - return "-webkit-tap-highlight-color"; - } - if (property.startsWith("-")) { - return property; - } - return camelCase(property); -}; - // @todo we don't parse correctly most of them if not all const prefixedProperties = [ "-webkit-box-orient", @@ -48,15 +28,43 @@ const prefixedProperties = [ const prefixes = ["webkit", "moz", "ms", "o"]; const prefixRegex = new RegExp(`^-(${prefixes.join("|")})-`); -const unprefixProperty = (property: string) => { +const normalizeProperty = (property: string): CssProperty => { + // convert unprefixed used by webflow version into prefixed one + if (property === "tap-highlight-color") { + return "-webkit-tap-highlight-color"; + } + if (property === "font-smoothing") { + return "-webkit-font-smoothing"; + } if (prefixedProperties.includes(property)) { - return property; + return property as CssProperty; + } + // remove old or unexpected prefixes + return property.replace(prefixRegex, "") as CssProperty; +}; + +/** + * Store prefixed properties without change + * and convert to camel case only unprefixed properties + * @todo stop converting to camel case and use hyphenated format + */ +export const camelCaseProperty = (property: CssProperty): StyleProperty => { + property = normalizeProperty(property); + // these are manually added with pascal case + if (property === "-webkit-font-smoothing") { + return "WebkitFontSmoothing"; + } + if (property === "-moz-osx-font-smoothing") { + return "MozOsxFontSmoothing"; + } + if (property.startsWith("-")) { + return property as StyleProperty; } - return property.replace(prefixRegex, ""); + return camelCase(property) as StyleProperty; }; const parseCssValue = ( - property: string, + property: CssProperty, value: string ): Map => { const expanded = new Map(expandShorthands([[property, value]])); @@ -71,10 +79,7 @@ const parseCssValue = ( final.set( property, - parseCssValueLonghand( - normalizePropertyName(property) as StyleProperty, - value - ) + parseCssValueLonghand(camelCaseProperty(property) as StyleProperty, value) ); } return final; @@ -227,17 +232,16 @@ export const parseCss = (css: string): ParsedStyleDecl[] => { const stringValue = csstree.generate(node.value); const parsedCss = parseCssValue( - unprefixProperty(node.property), + normalizeProperty(node.property), stringValue ); for (const { name: selector, state } of selectors) { for (const [property, value] of parsedCss) { + const normalizedProperty = normalizeProperty(property); const styleDecl: ParsedStyleDecl = { selector, - property: normalizePropertyName( - unprefixProperty(property) - ) as StyleProperty, + property: normalizedProperty, value, }; if (breakpoint) { @@ -248,7 +252,10 @@ export const parseCss = (css: string): ParsedStyleDecl[] => { } // deduplicate styles within selector and state by using map - styles.set(`${breakpoint}:${selector}:${state}:${property}`, styleDecl); + styles.set( + `${breakpoint}:${selector}:${state}:${normalizedProperty}`, + styleDecl + ); } } }); diff --git a/packages/css-data/src/shorthands.ts b/packages/css-data/src/shorthands.ts index bd9f2ec62ca0..25f9d3c23791 100644 --- a/packages/css-data/src/shorthands.ts +++ b/packages/css-data/src/shorthands.ts @@ -1,4 +1,4 @@ -import { cssWideKeywords } from "@webstudio-is/css-engine"; +import { cssWideKeywords, type CssProperty } from "@webstudio-is/css-engine"; import { List, parse, @@ -1401,7 +1401,7 @@ const parseValue = function* (property: string, value: string) { export const expandShorthands = ( shorthands: [property: string, value: string][] -): [property: string, value: string][] => { +): [property: CssProperty, value: string][] => { const longhands: [property: string, value: string][] = []; for (const [property, value] of shorthands) { const generator = parseValue(property, value); @@ -1432,5 +1432,5 @@ export const expandShorthands = ( } } } - return longhands; + return longhands as [property: CssProperty, value: string][]; }; diff --git a/packages/css-data/src/tailwind-parser/parse.test.ts b/packages/css-data/src/tailwind-parser/parse.test.ts index 7944581bae17..1feca109a216 100644 --- a/packages/css-data/src/tailwind-parser/parse.test.ts +++ b/packages/css-data/src/tailwind-parser/parse.test.ts @@ -40,15 +40,15 @@ describe("parseTailwindToWebstudio", () => { expect(await parseTailwindToWebstudio(tailwindClasses)).toEqual([ { - property: "textDecorationLine", + property: "text-decoration-line", value: { type: "keyword", value: "none" }, }, { - property: "textDecorationStyle", + property: "text-decoration-style", value: { type: "keyword", value: "solid" }, }, { - property: "textDecorationColor", + property: "text-decoration-color", value: { type: "keyword", value: "currentcolor" }, }, ]); @@ -59,19 +59,19 @@ describe("parseTailwindToWebstudio", () => { expect(await parseTailwindToWebstudio(tailwindClasses)).toEqual([ { - property: "marginTop", + property: "margin-top", value: { type: "unit", unit: "rem", value: 1 }, }, { - property: "marginRight", + property: "margin-right", value: { type: "unit", unit: "rem", value: 1 }, }, { - property: "marginBottom", + property: "margin-bottom", value: { type: "unit", unit: "rem", value: 1 }, }, { - property: "marginLeft", + property: "margin-left", value: { type: "unit", unit: "rem", value: 1 }, }, ]); @@ -80,173 +80,80 @@ describe("parseTailwindToWebstudio", () => { test("substitute variables - gradient", async () => { const tailwindClasses = `bg-left-top bg-gradient-to-r from-indigo-500 from-10% via-sky-500 via-30% to-emerald-500 to-90%`; - expect(await parseTailwindToWebstudio(tailwindClasses)) - .toMatchInlineSnapshot(` -[ - { - "property": "backgroundImage", - "value": { - "type": "layers", - "value": [ - { - "type": "unparsed", - "value": "linear-gradient(to right,rgb(99 102 241/1) 10%,rgb(14 165 233/1) 30%,rgb(16 185 129/1) 90%)", - }, - ], - }, - }, - { - "property": "backgroundPositionX", - "value": { - "type": "layers", - "value": [ - { - "type": "keyword", - "value": "left", - }, - ], - }, - }, - { - "property": "backgroundPositionY", - "value": { - "type": "layers", - "value": [ - { - "type": "keyword", - "value": "top", + expect(await parseTailwindToWebstudio(tailwindClasses)).toEqual([ + { + property: "background-image", + value: { + type: "layers", + value: [ + { + type: "unparsed", + value: + "linear-gradient(to right,rgb(99 102 241/1) 10%,rgb(14 165 233/1) 30%,rgb(16 185 129/1) 90%)", + }, + ], }, - ], - }, - }, -] -`); + }, + { + property: "background-position-x", + value: { type: "layers", value: [{ type: "keyword", value: "left" }] }, + }, + { + property: "background-position-y", + value: { type: "layers", value: [{ type: "keyword", value: "top" }] }, + }, + ]); }); test("shadow", async () => { const tailwindClasses = `shadow-md`; - expect(await parseTailwindToWebstudio(tailwindClasses)) - .toMatchInlineSnapshot(` -[ - { - "property": "boxShadow", - "value": { - "type": "layers", - "value": [ - { - "type": "tuple", - "value": [ - { - "type": "unit", - "unit": "number", - "value": 0, - }, - { - "type": "unit", - "unit": "number", - "value": 0, - }, - { - "alpha": 0, - "b": 0, - "g": 0, - "r": 0, - "type": "rgb", - }, - ], - }, - { - "type": "tuple", - "value": [ - { - "type": "unit", - "unit": "number", - "value": 0, - }, - { - "type": "unit", - "unit": "number", - "value": 0, - }, - { - "alpha": 0, - "b": 0, - "g": 0, - "r": 0, - "type": "rgb", - }, - ], - }, - { - "type": "tuple", - "value": [ - { - "type": "unit", - "unit": "number", - "value": 0, - }, - { - "type": "unit", - "unit": "px", - "value": 4, - }, - { - "type": "unit", - "unit": "px", - "value": 6, - }, - { - "type": "unit", - "unit": "px", - "value": -1, - }, - { - "alpha": 0.1, - "b": 0, - "g": 0, - "r": 0, - "type": "rgb", - }, - ], - }, - { - "type": "tuple", - "value": [ - { - "type": "unit", - "unit": "number", - "value": 0, - }, + expect(await parseTailwindToWebstudio(tailwindClasses)).toEqual([ + { + property: "box-shadow", + value: { + type: "layers", + value: [ { - "type": "unit", - "unit": "px", - "value": 2, + type: "tuple", + value: [ + { type: "unit", unit: "number", value: 0 }, + { type: "unit", unit: "number", value: 0 }, + { alpha: 0, b: 0, g: 0, r: 0, type: "rgb" }, + ], }, { - "type": "unit", - "unit": "px", - "value": 4, + type: "tuple", + value: [ + { type: "unit", unit: "number", value: 0 }, + { type: "unit", unit: "number", value: 0 }, + { alpha: 0, b: 0, g: 0, r: 0, type: "rgb" }, + ], }, { - "type": "unit", - "unit": "px", - "value": -2, + type: "tuple", + value: [ + { type: "unit", unit: "number", value: 0 }, + { type: "unit", unit: "px", value: 4 }, + { type: "unit", unit: "px", value: 6 }, + { type: "unit", unit: "px", value: -1 }, + { alpha: 0.1, b: 0, g: 0, r: 0, type: "rgb" }, + ], }, { - "alpha": 0.1, - "b": 0, - "g": 0, - "r": 0, - "type": "rgb", + type: "tuple", + value: [ + { type: "unit", unit: "number", value: 0 }, + { type: "unit", unit: "px", value: 2 }, + { type: "unit", unit: "px", value: 4 }, + { type: "unit", unit: "px", value: -2 }, + { alpha: 0.1, b: 0, g: 0, r: 0, type: "rgb" }, + ], }, ], }, - ], - }, - }, -] -`); + }, + ]); }); test("border", async () => { @@ -254,51 +161,51 @@ describe("parseTailwindToWebstudio", () => { expect(await parseTailwindToWebstudio(tailwindClasses)).toEqual([ { - property: "borderTopWidth", + property: "border-top-width", value: { type: "unit", unit: "px", value: 1 }, }, { - property: "borderRightWidth", + property: "border-right-width", value: { type: "unit", unit: "px", value: 1 }, }, { - property: "borderBottomWidth", + property: "border-bottom-width", value: { type: "unit", unit: "px", value: 1 }, }, { - property: "borderLeftWidth", + property: "border-left-width", value: { type: "unit", unit: "px", value: 1 }, }, { - property: "borderTopColor", + property: "border-top-color", value: { alpha: 1, b: 233, g: 165, r: 14, type: "rgb" }, }, { - property: "borderRightColor", + property: "border-right-color", value: { alpha: 1, b: 233, g: 165, r: 14, type: "rgb" }, }, { - property: "borderBottomColor", + property: "border-bottom-color", value: { alpha: 1, b: 233, g: 165, r: 14, type: "rgb" }, }, { - property: "borderLeftColor", + property: "border-left-color", value: { alpha: 1, b: 233, g: 165, r: 14, type: "rgb" }, }, { - property: "borderTopStyle", + property: "border-top-style", value: { type: "keyword", value: "solid" }, }, { - property: "borderRightStyle", + property: "border-right-style", value: { type: "keyword", value: "solid" }, }, { - property: "borderBottomStyle", + property: "border-bottom-style", value: { type: "keyword", value: "solid" }, }, { - property: "borderLeftStyle", + property: "border-left-style", value: { type: "keyword", value: "solid" }, }, ]); diff --git a/packages/css-data/src/tailwind-parser/parse.ts b/packages/css-data/src/tailwind-parser/parse.ts index 37f339d9ff65..d382be65b594 100644 --- a/packages/css-data/src/tailwind-parser/parse.ts +++ b/packages/css-data/src/tailwind-parser/parse.ts @@ -42,10 +42,10 @@ export const parseTailwindToCss = async ( **/ const postprocessBorder = (styles: Omit[]) => { const borderPairs = [ - ["borderTopWidth", "borderTopStyle"], - ["borderRightWidth", "borderRightStyle"], - ["borderBottomWidth", "borderBottomStyle"], - ["borderLeftWidth", "borderLeftStyle"], + ["border-top-width", "border-top-style"], + ["border-right-width", "border-right-style"], + ["border-bottom-width", "border-bottom-style"], + ["border-left-width", "border-left-style"], ] as const; const resultStyles = [...styles]; diff --git a/packages/css-engine/src/__generated__/types.ts b/packages/css-engine/src/__generated__/types.ts index 8347da31215a..ff109f02b318 100644 --- a/packages/css-engine/src/__generated__/types.ts +++ b/packages/css-engine/src/__generated__/types.ts @@ -1,4 +1,4 @@ -export type Property = +export type CamelCasedProperty = | "WebkitFontSmoothing" | "MozOsxFontSmoothing" | "-webkit-box-orient" @@ -337,6 +337,345 @@ export type Property = | "zIndex" | "zoom"; +export type HyphenatedProperty = + | "-webkit-font-smoothing" + | "-moz-osx-font-smoothing" + | "-webkit-box-orient" + | "view-timeline-name" + | "scroll-timeline-name" + | "-webkit-line-clamp" + | "-webkit-overflow-scrolling" + | "-webkit-tap-highlight-color" + | "accent-color" + | "align-content" + | "align-items" + | "align-self" + | "animation-delay" + | "animation-direction" + | "animation-duration" + | "animation-fill-mode" + | "animation-iteration-count" + | "animation-name" + | "animation-play-state" + | "animation-timing-function" + | "appearance" + | "aspect-ratio" + | "backdrop-filter" + | "backface-visibility" + | "background-attachment" + | "background-blend-mode" + | "background-clip" + | "background-color" + | "background-image" + | "background-origin" + | "background-position-x" + | "background-position-y" + | "background-repeat" + | "background-size" + | "block-size" + | "border-block-color" + | "border-block-style" + | "border-block-width" + | "border-block-end-color" + | "border-block-end-style" + | "border-block-end-width" + | "border-block-start-color" + | "border-block-start-style" + | "border-block-start-width" + | "border-bottom-color" + | "border-bottom-left-radius" + | "border-bottom-right-radius" + | "border-bottom-style" + | "border-bottom-width" + | "border-collapse" + | "border-end-end-radius" + | "border-end-start-radius" + | "border-image-outset" + | "border-image-repeat" + | "border-image-slice" + | "border-image-source" + | "border-image-width" + | "border-inline-color" + | "border-inline-style" + | "border-inline-width" + | "border-inline-end-color" + | "border-inline-end-style" + | "border-inline-end-width" + | "border-inline-start-color" + | "border-inline-start-style" + | "border-inline-start-width" + | "border-left-color" + | "border-left-style" + | "border-left-width" + | "border-right-color" + | "border-right-style" + | "border-right-width" + | "border-spacing" + | "border-start-end-radius" + | "border-start-start-radius" + | "border-top-color" + | "border-top-left-radius" + | "border-top-right-radius" + | "border-top-style" + | "border-top-width" + | "bottom" + | "box-decoration-break" + | "box-shadow" + | "box-sizing" + | "break-after" + | "break-before" + | "break-inside" + | "caption-side" + | "caret-color" + | "clear" + | "clip" + | "clip-path" + | "color" + | "color-scheme" + | "column-count" + | "column-fill" + | "column-gap" + | "column-rule-color" + | "column-rule-style" + | "column-rule-width" + | "column-span" + | "column-width" + | "contain" + | "contain-intrinsic-block-size" + | "contain-intrinsic-height" + | "contain-intrinsic-inline-size" + | "contain-intrinsic-width" + | "container-name" + | "container-type" + | "content" + | "content-visibility" + | "counter-increment" + | "counter-reset" + | "counter-set" + | "cursor" + | "direction" + | "display" + | "empty-cells" + | "field-sizing" + | "filter" + | "flex-basis" + | "flex-direction" + | "flex-grow" + | "flex-shrink" + | "flex-wrap" + | "float" + | "font-family" + | "font-feature-settings" + | "font-kerning" + | "font-language-override" + | "font-optical-sizing" + | "font-variation-settings" + | "font-size" + | "font-size-adjust" + | "font-stretch" + | "font-style" + | "font-synthesis-small-caps" + | "font-synthesis-style" + | "font-synthesis-weight" + | "font-variant-alternates" + | "font-variant-caps" + | "font-variant-east-asian" + | "font-variant-ligatures" + | "font-variant-numeric" + | "font-variant-position" + | "font-weight" + | "grid-auto-columns" + | "grid-auto-flow" + | "grid-auto-rows" + | "grid-column-end" + | "grid-column-start" + | "grid-row-end" + | "grid-row-start" + | "grid-template-areas" + | "grid-template-columns" + | "grid-template-rows" + | "hanging-punctuation" + | "height" + | "hyphenate-character" + | "hyphenate-limit-chars" + | "hyphens" + | "image-orientation" + | "image-rendering" + | "inline-size" + | "inset-block-end" + | "inset-block-start" + | "inset-inline-end" + | "inset-inline-start" + | "isolation" + | "justify-content" + | "justify-items" + | "justify-self" + | "left" + | "letter-spacing" + | "line-break" + | "line-height" + | "list-style-image" + | "list-style-position" + | "list-style-type" + | "margin-block-end" + | "margin-block-start" + | "margin-bottom" + | "margin-inline-end" + | "margin-inline-start" + | "margin-left" + | "margin-right" + | "margin-top" + | "mask-border-mode" + | "mask-border-outset" + | "mask-border-repeat" + | "mask-border-slice" + | "mask-border-source" + | "mask-border-width" + | "mask-clip" + | "mask-composite" + | "mask-image" + | "mask-mode" + | "mask-origin" + | "mask-position" + | "mask-repeat" + | "mask-size" + | "mask-type" + | "math-depth" + | "math-shift" + | "math-style" + | "max-block-size" + | "max-height" + | "max-inline-size" + | "max-width" + | "min-block-size" + | "min-height" + | "min-inline-size" + | "min-width" + | "mix-blend-mode" + | "object-fit" + | "object-position" + | "offset-anchor" + | "offset-distance" + | "offset-path" + | "offset-position" + | "offset-rotate" + | "opacity" + | "order" + | "orphans" + | "outline-color" + | "outline-offset" + | "outline-style" + | "outline-width" + | "overflow-wrap" + | "overflow-x" + | "overflow-y" + | "overscroll-behavior" + | "overscroll-behavior-block" + | "overscroll-behavior-inline" + | "overscroll-behavior-x" + | "overscroll-behavior-y" + | "padding-block-end" + | "padding-block-start" + | "padding-bottom" + | "padding-inline-end" + | "padding-inline-start" + | "padding-left" + | "padding-right" + | "padding-top" + | "page" + | "page-break-after" + | "page-break-before" + | "page-break-inside" + | "paint-order" + | "perspective" + | "perspective-origin" + | "pointer-events" + | "position" + | "print-color-adjust" + | "quotes" + | "resize" + | "right" + | "rotate" + | "row-gap" + | "scale" + | "scrollbar-color" + | "scrollbar-gutter" + | "scrollbar-width" + | "scroll-behavior" + | "scroll-margin-block-start" + | "scroll-margin-block-end" + | "scroll-margin-bottom" + | "scroll-margin-inline-start" + | "scroll-margin-inline-end" + | "scroll-margin-left" + | "scroll-margin-right" + | "scroll-margin-top" + | "scroll-padding-block-start" + | "scroll-padding-block-end" + | "scroll-padding-bottom" + | "scroll-padding-inline-start" + | "scroll-padding-inline-end" + | "scroll-padding-left" + | "scroll-padding-right" + | "scroll-padding-top" + | "scroll-snap-align" + | "scroll-snap-stop" + | "scroll-snap-type" + | "shape-image-threshold" + | "shape-margin" + | "shape-outside" + | "tab-size" + | "table-layout" + | "text-align" + | "text-align-last" + | "text-combine-upright" + | "text-decoration-color" + | "text-decoration-line" + | "text-decoration-skip-ink" + | "text-decoration-style" + | "text-decoration-thickness" + | "text-emphasis-color" + | "text-emphasis-position" + | "text-emphasis-style" + | "text-indent" + | "text-justify" + | "text-orientation" + | "text-overflow" + | "text-rendering" + | "text-shadow" + | "text-size-adjust" + | "text-transform" + | "text-underline-offset" + | "text-underline-position" + | "text-wrap-mode" + | "text-wrap-style" + | "top" + | "touch-action" + | "transform" + | "transform-box" + | "transform-origin" + | "transform-style" + | "transition-behavior" + | "transition-delay" + | "transition-duration" + | "transition-property" + | "transition-timing-function" + | "translate" + | "unicode-bidi" + | "user-select" + | "vertical-align" + | "visibility" + | "white-space-collapse" + | "widows" + | "width" + | "will-change" + | "word-break" + | "word-spacing" + | "word-wrap" + | "writing-mode" + | "z-index" + | "zoom"; + export type Unit = | "%" | "deg" diff --git a/packages/css-engine/src/index.ts b/packages/css-engine/src/index.ts index 5025b8a23949..9f320732812a 100644 --- a/packages/css-engine/src/index.ts +++ b/packages/css-engine/src/index.ts @@ -3,7 +3,4 @@ export * from "./schema"; export * from "./css"; // necessary for sdk dts generation -export type { - Property as __Property, - Unit as __Unit, -} from "./__generated__/types"; +export type { Unit as __Unit } from "./__generated__/types"; diff --git a/packages/css-engine/src/schema.ts b/packages/css-engine/src/schema.ts index ea2246c74a6c..50a75bea99ee 100644 --- a/packages/css-engine/src/schema.ts +++ b/packages/css-engine/src/schema.ts @@ -1,13 +1,16 @@ import { z } from "zod"; import type { - Property as GeneratedProperty, + CamelCasedProperty, + HyphenatedProperty, Unit as GeneratedUnit, } from "./__generated__/types"; import { toValue, type TransformValue } from "./core/to-value"; export type CustomProperty = `--${string}`; -export type StyleProperty = GeneratedProperty | CustomProperty; +export type StyleProperty = CamelCasedProperty | CustomProperty; + +export type CssProperty = HyphenatedProperty | CustomProperty; const Unit = z.string() as z.ZodType; diff --git a/packages/jsx-utils/src/tw-to-webstudio.ts b/packages/jsx-utils/src/tw-to-webstudio.ts index f7e603c7f43c..abd8963c1ffc 100644 --- a/packages/jsx-utils/src/tw-to-webstudio.ts +++ b/packages/jsx-utils/src/tw-to-webstudio.ts @@ -1,5 +1,8 @@ import type { WsEmbedTemplate } from "@webstudio-is/sdk"; -import { parseTailwindToWebstudio } from "@webstudio-is/css-data"; +import { + camelCaseProperty, + parseTailwindToWebstudio, +} from "@webstudio-is/css-data"; import { traverseTemplateAsync } from "./traverse-template"; export const tailwindToWebstudio = async (template: WsEmbedTemplate) => { @@ -9,20 +12,28 @@ export const tailwindToWebstudio = async (template: WsEmbedTemplate) => { (prop) => prop.name === "className" ); if (classNameProp && classNameProp.type === "string") { - const styles = await parseTailwindToWebstudio(classNameProp.value); + const hyphenatedStyles = await parseTailwindToWebstudio( + classNameProp.value + ); + const newStyles = hyphenatedStyles.map( + ({ property, ...styleDecl }) => ({ + ...styleDecl, + property: camelCaseProperty(property), + }) + ); if (node.styles !== undefined) { // Merge with existing styles const currentStyles = new Set( node.styles.map(({ property }) => property) ); - for (const styleDecl of styles) { + for (const styleDecl of newStyles) { if (currentStyles.has(styleDecl.property) === false) { node.styles.push(styleDecl); } } } else { - node.styles = styles; + node.styles = newStyles; } // @todo Instead of deleting className remove Tailwind CSS classes and leave the remaning ones. diff --git a/packages/sdk/scripts/normalize.css.ts b/packages/sdk/scripts/normalize.css.ts index 22ef921c3d4c..34e66381e021 100644 --- a/packages/sdk/scripts/normalize.css.ts +++ b/packages/sdk/scripts/normalize.css.ts @@ -1,5 +1,5 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { parseCss } from "@webstudio-is/css-data"; +import { camelCaseProperty, parseCss } from "@webstudio-is/css-data"; import htmlTags from "html-tags"; const mapGroupBy = ( @@ -48,7 +48,7 @@ for (const [tag, styles] of groups) { } const newStyles = styles.map(({ state, property, value }) => ({ state, - property, + property: camelCaseProperty(property), value, })); let serializedStyles = JSON.stringify(newStyles); diff --git a/packages/template/src/css.ts b/packages/template/src/css.ts index 582fae917c5f..5496a6583dbe 100644 --- a/packages/template/src/css.ts +++ b/packages/template/src/css.ts @@ -1,4 +1,4 @@ -import { parseCss } from "@webstudio-is/css-data"; +import { camelCaseProperty, parseCss } from "@webstudio-is/css-data"; import type { StyleProperty, StyleValue } from "@webstudio-is/css-engine"; export type TemplateStyleDecl = { @@ -14,7 +14,7 @@ export const css = ( const cssString = `.styles{ ${String.raw({ raw: strings }, ...values)} }`; const styles: TemplateStyleDecl[] = []; for (const { state, property, value } of parseCss(cssString)) { - styles.push({ state, property, value }); + styles.push({ state, property: camelCaseProperty(property), value }); } return styles; };