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