From 0d1d716b029d1e21f7cf031bc7d5c55ba0a800b6 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 18 May 2025 00:34:04 +0300 Subject: [PATCH 1/4] experimental: paste tailwind Ref https://github.com/webstudio-is/webstudio/issues/2651 Added "Paste Html With Tailwind Classes" command Also fixed a few issues with css parser. --- apps/builder/app/builder/shared/commands.ts | 13 + .../css-editor/parse-style-input.test.ts | 4 +- apps/builder/app/shared/html.test.tsx | 21 +- apps/builder/app/shared/html.ts | 11 +- .../app/shared/style-object-model.test.tsx | 29 + .../tailwind/__generated__/preflight.ts | 975 ++++++++++++++++++ .../app/shared/tailwind/preflight-bin.ts | 25 + .../builder/app/shared/tailwind/preflight.css | 393 +++++++ .../app/shared/tailwind/tailwind.test.tsx | 322 ++++++ apps/builder/app/shared/tailwind/tailwind.ts | 202 ++++ apps/builder/package.json | 4 + packages/css-data/package.json | 6 +- packages/css-data/src/parse-css-value.test.ts | 7 + packages/css-data/src/parse-css-value.ts | 5 + packages/css-data/src/parse-css.test.ts | 10 + packages/css-data/src/parse-css.ts | 7 - packages/css-data/src/shorthands.test.ts | 14 +- packages/css-data/src/shorthands.ts | 35 +- packages/template/src/css.ts | 5 +- packages/template/src/jsx.test.tsx | 36 + packages/template/src/jsx.ts | 114 +- pnpm-lock.yaml | 75 +- 22 files changed, 2212 insertions(+), 101 deletions(-) create mode 100644 apps/builder/app/shared/tailwind/__generated__/preflight.ts create mode 100644 apps/builder/app/shared/tailwind/preflight-bin.ts create mode 100644 apps/builder/app/shared/tailwind/preflight.css create mode 100644 apps/builder/app/shared/tailwind/tailwind.test.tsx create mode 100644 apps/builder/app/shared/tailwind/tailwind.ts diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 119f66dab503..3aa0517ff8d2 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -25,6 +25,7 @@ import { import { deleteInstanceMutable, extractWebstudioFragment, + insertWebstudioFragmentAt, insertWebstudioFragmentCopy, updateWebstudioData, } from "~/shared/instance-utils"; @@ -48,6 +49,8 @@ import { isRichTextContent, isTreeSatisfyingContentModel, } from "~/shared/content-model"; +import { generateFragmentFromHtml } from "~/shared/html"; +import { generateFragmentFromTailwind } from "~/shared/tailwind/tailwind"; export const $styleSourceInputElement = atom(); @@ -519,6 +522,16 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ handler: () => unwrap(), }, + { + name: "pasteHtmlWithTailwindClasses", + handler: async () => { + let html = await navigator.clipboard.readText(); + let fragment = generateFragmentFromHtml(html); + fragment = await generateFragmentFromTailwind(fragment); + return insertWebstudioFragmentAt(fragment); + }, + }, + // history { diff --git a/apps/builder/app/builder/shared/css-editor/parse-style-input.test.ts b/apps/builder/app/builder/shared/css-editor/parse-style-input.test.ts index 7f815c5f6ec5..776a343960ce 100644 --- a/apps/builder/app/builder/shared/css-editor/parse-style-input.test.ts +++ b/apps/builder/app/builder/shared/css-editor/parse-style-input.test.ts @@ -5,7 +5,7 @@ describe("parseStyleInput", () => { test("parses custom property", () => { const result = parseStyleInput("--custom-color"); expect(result).toEqual( - new Map([["--custom-color", { type: "keyword", value: "unset" }]]) + new Map([["--custom-color", { type: "unparsed", value: "" }]]) ); }); @@ -43,7 +43,7 @@ describe("parseStyleInput", () => { test("converts unknown property to custom property assuming user forgot to add --", () => { const result = parseStyleInput("notaproperty"); expect(result).toEqual( - new Map([["--notaproperty", { type: "keyword", value: "unset" }]]) + new Map([["--notaproperty", { type: "unparsed", value: "" }]]) ); }); diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 30fa970f8623..9458c0abca38 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -223,15 +223,20 @@ test("generate style attribute as local styles", () => { ); }); -test("paste svg as html embed", () => { +test("optionally paste svg as html embed", () => { expect( - generateFragmentFromHtml(` -
- - - -
- `) + generateFragmentFromHtml( + ` +
+ + + +
+ `, + { + unknownTags: true, + } + ) ).toEqual( renderTemplate( diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index 1d4bd887e3dd..5bb517367553 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -71,7 +71,10 @@ const findContentTags = (element: ElementNode, tags = new Set()) => { return tags; }; -export const generateFragmentFromHtml = (html: string): WebstudioFragment => { +export const generateFragmentFromHtml = ( + html: string, + options?: { unknownTags?: boolean } +): WebstudioFragment => { const attributeTypes = getAttributeTypes(); const instances = new Map(); const styleSourceSelections: StyleSourceSelection[] = []; @@ -112,7 +115,11 @@ export const generateFragmentFromHtml = (html: string): WebstudioFragment => { }; const convertElementToInstance = (node: ElementNode) => { - if (node.tagName === "svg" && node.sourceCodeLocation) { + if ( + node.tagName === "svg" && + node.sourceCodeLocation && + options?.unknownTags + ) { const { startCol, startOffset, endOffset } = node.sourceCodeLocation; const indent = startCol - 1; const htmlFragment = html diff --git a/apps/builder/app/shared/style-object-model.test.tsx b/apps/builder/app/shared/style-object-model.test.tsx index 6110610adda1..52659d9741cc 100644 --- a/apps/builder/app/shared/style-object-model.test.tsx +++ b/apps/builder/app/shared/style-object-model.test.tsx @@ -517,6 +517,35 @@ test("support custom properties in unparsed values", () => { }); }); +test("support empty custom properties", () => { + const model = createModel({ + css: ` + bodyLocal { + --inset: ; + box-shadow: var(--inset) red; + } + `, + jsx: <$.Body ws:id="body" class="bodyLocal">, + }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + property: "--inset", + }).computedValue + ).toEqual({ type: "unparsed", value: "" }); + expect( + getComputedStyleDecl({ + model, + instanceSelector: ["body"], + property: "box-shadow", + }).computedValue + ).toEqual({ + type: "layers", + value: [{ type: "unparsed", value: "red" }], + }); +}); + test("use fallback value when custom property does not exist", () => { const model = createModel({ css: ` diff --git a/apps/builder/app/shared/tailwind/__generated__/preflight.ts b/apps/builder/app/shared/tailwind/__generated__/preflight.ts new file mode 100644 index 000000000000..383118f5d8bb --- /dev/null +++ b/apps/builder/app/shared/tailwind/__generated__/preflight.ts @@ -0,0 +1,975 @@ +import type { CssProperty, StyleValue } from "@webstudio-is/css-engine"; + +export const preflight: Record< + string, + undefined | { property: CssProperty; value: StyleValue }[] +> = { + html: [ + { + property: "line-height", + value: { type: "unit", unit: "number", value: 1.5 }, + }, + { + property: "text-size-adjust", + value: { type: "unit", unit: "%", value: 100 }, + }, + { property: "tab-size", value: { type: "unit", unit: "number", value: 4 } }, + { + property: "font-family", + value: { + type: "fontFamily", + value: [ + "ui-sans-serif", + "system-ui", + "sans-serif", + "Apple Color Emoji", + "Segoe UI Emoji", + "Segoe UI Symbol", + "Noto Color Emoji", + ], + }, + }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "normal" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "normal" }, + }, + { + property: "-webkit-tap-highlight-color", + value: { type: "keyword", value: "transparent" }, + }, + ], + body: [ + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { property: "line-height", value: { type: "keyword", value: "inherit" } }, + ], + hr: [ + { property: "height", value: { type: "unit", unit: "number", value: 0 } }, + { property: "color", value: { type: "keyword", value: "inherit" } }, + { + property: "border-top-width", + value: { type: "unit", unit: "px", value: 1 }, + }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + h1: [ + { property: "font-size", value: { type: "keyword", value: "inherit" } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + h2: [ + { property: "font-size", value: { type: "keyword", value: "inherit" } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + h3: [ + { property: "font-size", value: { type: "keyword", value: "inherit" } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + h4: [ + { property: "font-size", value: { type: "keyword", value: "inherit" } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + h5: [ + { property: "font-size", value: { type: "keyword", value: "inherit" } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + h6: [ + { property: "font-size", value: { type: "keyword", value: "inherit" } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + a: [ + { property: "color", value: { type: "keyword", value: "inherit" } }, + { + property: "text-decoration-line", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "text-decoration-style", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "text-decoration-color", + value: { type: "keyword", value: "inherit" }, + }, + ], + b: [{ property: "font-weight", value: { type: "keyword", value: "bolder" } }], + strong: [ + { property: "font-weight", value: { type: "keyword", value: "bolder" } }, + ], + code: [ + { + property: "font-family", + value: { + type: "fontFamily", + value: [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + "monospace", + ], + }, + }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "normal" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "normal" }, + }, + { property: "font-size", value: { type: "unit", unit: "em", value: 1 } }, + ], + kbd: [ + { + property: "font-family", + value: { + type: "fontFamily", + value: [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + "monospace", + ], + }, + }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "normal" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "normal" }, + }, + { property: "font-size", value: { type: "unit", unit: "em", value: 1 } }, + ], + samp: [ + { + property: "font-family", + value: { + type: "fontFamily", + value: [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + "monospace", + ], + }, + }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "normal" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "normal" }, + }, + { property: "font-size", value: { type: "unit", unit: "em", value: 1 } }, + ], + pre: [ + { + property: "font-family", + value: { + type: "fontFamily", + value: [ + "ui-monospace", + "SFMono-Regular", + "Menlo", + "Monaco", + "Consolas", + "Liberation Mono", + "Courier New", + "monospace", + ], + }, + }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "normal" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "normal" }, + }, + { property: "font-size", value: { type: "unit", unit: "em", value: 1 } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + small: [ + { property: "font-size", value: { type: "unit", unit: "%", value: 80 } }, + ], + sub: [ + { property: "font-size", value: { type: "unit", unit: "%", value: 75 } }, + { + property: "line-height", + value: { type: "unit", unit: "number", value: 0 }, + }, + { property: "position", value: { type: "keyword", value: "relative" } }, + { + property: "vertical-align", + value: { type: "keyword", value: "baseline" }, + }, + { property: "bottom", value: { type: "unit", unit: "em", value: -0.25 } }, + ], + sup: [ + { property: "font-size", value: { type: "unit", unit: "%", value: 75 } }, + { + property: "line-height", + value: { type: "unit", unit: "number", value: 0 }, + }, + { property: "position", value: { type: "keyword", value: "relative" } }, + { + property: "vertical-align", + value: { type: "keyword", value: "baseline" }, + }, + { property: "top", value: { type: "unit", unit: "em", value: -0.5 } }, + ], + table: [ + { + property: "text-indent", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "border-top-color", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "border-right-color", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "border-bottom-color", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "border-left-color", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "border-collapse", + value: { type: "keyword", value: "collapse" }, + }, + ], + button: [ + { property: "font-family", value: { type: "keyword", value: "inherit" } }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "inherit" }, + }, + { property: "font-size", value: { type: "unit", unit: "%", value: 100 } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { property: "line-height", value: { type: "keyword", value: "inherit" } }, + { property: "color", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { property: "text-transform", value: { type: "keyword", value: "none" } }, + { property: "appearance", value: { type: "keyword", value: "button" } }, + { + property: "background-color", + value: { type: "keyword", value: "transparent" }, + }, + { + property: "background-image", + value: { type: "layers", value: [{ type: "keyword", value: "none" }] }, + }, + { property: "cursor", value: { type: "keyword", value: "pointer" } }, + ], + input: [ + { property: "font-family", value: { type: "keyword", value: "inherit" } }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "inherit" }, + }, + { property: "font-size", value: { type: "unit", unit: "%", value: 100 } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { property: "line-height", value: { type: "keyword", value: "inherit" } }, + { property: "color", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + optgroup: [ + { property: "font-family", value: { type: "keyword", value: "inherit" } }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "inherit" }, + }, + { property: "font-size", value: { type: "unit", unit: "%", value: 100 } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { property: "line-height", value: { type: "keyword", value: "inherit" } }, + { property: "color", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + select: [ + { property: "font-family", value: { type: "keyword", value: "inherit" } }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "inherit" }, + }, + { property: "font-size", value: { type: "unit", unit: "%", value: 100 } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { property: "line-height", value: { type: "keyword", value: "inherit" } }, + { property: "color", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { property: "text-transform", value: { type: "keyword", value: "none" } }, + ], + textarea: [ + { property: "font-family", value: { type: "keyword", value: "inherit" } }, + { + property: "font-feature-settings", + value: { type: "keyword", value: "inherit" }, + }, + { + property: "font-variation-settings", + value: { type: "keyword", value: "inherit" }, + }, + { property: "font-size", value: { type: "unit", unit: "%", value: 100 } }, + { property: "font-weight", value: { type: "keyword", value: "inherit" } }, + { property: "line-height", value: { type: "keyword", value: "inherit" } }, + { property: "color", value: { type: "keyword", value: "inherit" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { property: "resize", value: { type: "keyword", value: "vertical" } }, + ], + progress: [ + { + property: "vertical-align", + value: { type: "keyword", value: "baseline" }, + }, + ], + summary: [ + { property: "display", value: { type: "keyword", value: "list-item" } }, + ], + blockquote: [ + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + dl: [ + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + dd: [ + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + figure: [ + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + p: [ + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + fieldset: [ + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + legend: [ + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + ol: [ + { + property: "list-style-position", + value: { type: "keyword", value: "outside" }, + }, + { property: "list-style-image", value: { type: "keyword", value: "none" } }, + { property: "list-style-type", value: { type: "keyword", value: "none" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + ul: [ + { + property: "list-style-position", + value: { type: "keyword", value: "outside" }, + }, + { property: "list-style-image", value: { type: "keyword", value: "none" } }, + { property: "list-style-type", value: { type: "keyword", value: "none" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + menu: [ + { + property: "list-style-position", + value: { type: "keyword", value: "outside" }, + }, + { property: "list-style-image", value: { type: "keyword", value: "none" } }, + { property: "list-style-type", value: { type: "keyword", value: "none" } }, + { + property: "margin-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "margin-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + dialog: [ + { + property: "padding-top", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-right", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-bottom", + value: { type: "unit", unit: "number", value: 0 }, + }, + { + property: "padding-left", + value: { type: "unit", unit: "number", value: 0 }, + }, + ], + img: [ + { property: "display", value: { type: "keyword", value: "block" } }, + { property: "vertical-align", value: { type: "keyword", value: "middle" } }, + { property: "max-width", value: { type: "unit", unit: "%", value: 100 } }, + { property: "height", value: { type: "keyword", value: "auto" } }, + ], + video: [ + { property: "display", value: { type: "keyword", value: "block" } }, + { property: "vertical-align", value: { type: "keyword", value: "middle" } }, + { property: "max-width", value: { type: "unit", unit: "%", value: 100 } }, + { property: "height", value: { type: "keyword", value: "auto" } }, + ], + canvas: [ + { property: "display", value: { type: "keyword", value: "block" } }, + { property: "vertical-align", value: { type: "keyword", value: "middle" } }, + ], + audio: [ + { property: "display", value: { type: "keyword", value: "block" } }, + { property: "vertical-align", value: { type: "keyword", value: "middle" } }, + ], + iframe: [ + { property: "display", value: { type: "keyword", value: "block" } }, + { property: "vertical-align", value: { type: "keyword", value: "middle" } }, + ], + embed: [ + { property: "display", value: { type: "keyword", value: "block" } }, + { property: "vertical-align", value: { type: "keyword", value: "middle" } }, + ], + object: [ + { property: "display", value: { type: "keyword", value: "block" } }, + { property: "vertical-align", value: { type: "keyword", value: "middle" } }, + ], +}; diff --git a/apps/builder/app/shared/tailwind/preflight-bin.ts b/apps/builder/app/shared/tailwind/preflight-bin.ts new file mode 100644 index 000000000000..94a57bc74b6b --- /dev/null +++ b/apps/builder/app/shared/tailwind/preflight-bin.ts @@ -0,0 +1,25 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { parseCss } from "@webstudio-is/css-data"; +import type { CssProperty, StyleValue } from "@webstudio-is/css-engine"; +import { tags } from "@webstudio-is/sdk"; + +const cssFile = new URL("./preflight.css", import.meta.url); +const css = await readFile(cssFile, "utf8"); +const parsed = parseCss(css); +const result: Record = + {}; +for (const { selector, breakpoint, ...styleDecl } of parsed) { + if (tags.includes(selector) && styleDecl.state === undefined) { + result[selector] ??= []; + result[selector].push(styleDecl); + } +} +let code = ""; +code += `import type { CssProperty, StyleValue } from "@webstudio-is/css-engine";\n\n`; +const type = + "Record"; +code += `export const preflight: ${type} = ${JSON.stringify(result)}`; + +const generatedFile = new URL("./__generated__/preflight.ts", import.meta.url); +await mkdir(new URL(".", generatedFile), { recursive: true }); +await writeFile(generatedFile, code); diff --git a/apps/builder/app/shared/tailwind/preflight.css b/apps/builder/app/shared/tailwind/preflight.css new file mode 100644 index 000000000000..a9812dd8a0d5 --- /dev/null +++ b/apps/builder/app/shared/tailwind/preflight.css @@ -0,0 +1,393 @@ +/* https://github.com/unocss/unocss/blob/81b4946d3ab24dff5c5940963d46d6082c52688f/packages-presets/reset/tailwind-compat.css */ +/* https://github.com/tailwindlabs/tailwindcss/blob/449dfcf00d547dc6609466ef47840aa23bb0f11f/packages/tailwindcss/preflight.css */ + +/* +Please read: https://github.com/unocss/unocss/blob/main/packages-presets/reset/tailwind-compat.md +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +2. [UnoCSS]: allow to override the default border color with css var `--un-default-border-color` +*/ + +*, +::before, +::after { + box-sizing: border-box; /* 1 */ + border-width: 0; /* 2 */ + border-style: solid; /* 2 */ + border-color: var(--un-default-border-color, #e5e7eb); /* 2 */ +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS. +*/ + +html, +:host { + line-height: 1.5; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ + -moz-tab-size: 4; /* 3 */ + tab-size: 4; /* 3 */ + font-family: + ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", + "Segoe UI Symbol", "Noto Color Emoji"; /* 4 */ + font-feature-settings: normal; /* 5 */ + font-variation-settings: normal; /* 6 */ + -webkit-tap-highlight-color: transparent; /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; /* 1 */ + line-height: inherit; /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; /* 1 */ + color: inherit; /* 2 */ + border-top-width: 1px; /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; /* 1 */ + font-feature-settings: normal; /* 2 */ + font-variation-settings: normal; /* 3 */ + font-size: 1em; /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; /* 1 */ + border-color: inherit; /* 2 */ + border-collapse: collapse; /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-feature-settings: inherit; /* 1 */ + font-variation-settings: inherit; /* 1 */ + font-size: 100%; /* 1 */ + font-weight: inherit; /* 1 */ + line-height: inherit; /* 1 */ + color: inherit; /* 1 */ + margin: 0; /* 2 */ + padding: 0; /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; /* 1 */ + background-color: transparent; /* 2 */ + background-image: none; /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::placeholder, +textarea::placeholder { + opacity: 1; /* 1 */ + color: #9ca3af; /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; /* 1 */ + vertical-align: middle; /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* +Make elements with the HTML hidden attribute stay hidden by default. +*/ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx new file mode 100644 index 000000000000..c1ebda7d3b48 --- /dev/null +++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx @@ -0,0 +1,322 @@ +import { expect, test } from "vitest"; +import { css, renderTemplate, ws } from "@webstudio-is/template"; +import { generateFragmentFromTailwind } from "./tailwind"; + +test("extract local styles from tailwind classes", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + + + ) + ) + ).toEqual( + renderTemplate( + + + + ) + ); +}); + +test("ignore dark mode", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("ignore empty class", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate() + ) + ).toEqual(renderTemplate()); +}); + +test("preserve custom class", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("generate border", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate() + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("override border opacity", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("generate shadow", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate() + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("preserve or override existing local styles", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("add preflight matching tags", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("add preflight matching tags when no classes are used", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate() + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("extract states from tailwind classes", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("extract new breakpoints from tailwind classes", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); + +test("merge tailwind breakpoints with already defined ones", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); +}); diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts new file mode 100644 index 000000000000..e4d3e1d43602 --- /dev/null +++ b/apps/builder/app/shared/tailwind/tailwind.ts @@ -0,0 +1,202 @@ +import { createGenerator } from "@unocss/core"; +import { presetLegacyCompat } from "@unocss/preset-legacy-compat"; +import { presetWind3 } from "@unocss/preset-wind3"; +import { + camelCaseProperty, + parseCss, + parseMediaQuery, + type ParsedStyleDecl, +} from "@webstudio-is/css-data"; +import { + getStyleDeclKey, + type Instance, + type Prop, + type WebstudioFragment, +} from "@webstudio-is/sdk"; +import { isBaseBreakpoint } from "../nano-states"; +import { preflight } from "./__generated__/preflight"; + +const createUnoGenerator = async () => { + return await createGenerator({ + presets: [ + presetWind3({ + // css variables are defined on the same element as style + preflight: "on-demand", + // dark mode will be ignored by parser + dark: "media", + }), + // until we support oklch natively + presetLegacyCompat({ legacyColorSpace: true }), + ], + }); +}; + +const parseTailwindClasses = async (classes: string) => { + // avoid caching uno generator instance + // to prevent bloating css with preflights from previous calls + const generator = await createUnoGenerator(); + const generated = await generator.generate(classes); + let css = generated.css; + let parsedStyles: Omit[] = []; + // @todo probably builtin in v4 + if (css.includes("border")) { + // Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) + // [UnoCSS]: allow to override the default border color with css var `--un-default-border-color` + const reset = `.styles { + border-style: solid; + border-color: var(--un-default-border-color, #e5e7eb); + border-width: 0; + }`; + parsedStyles.push(...parseCss(reset)); + } + parsedStyles.push(...parseCss(css)); + // skip preflights with ::before, ::after and ::backdrop + parsedStyles = parsedStyles.filter( + (styleDecl) => !styleDecl.state?.startsWith("::") + ); + const newClasses = classes + .split(" ") + .filter((item) => !generated.matched.has(item)) + .join(" "); + return { newClasses: newClasses, parsedStyles }; +}; + +const getUniqueIdForList = >(list: List) => { + const existingIds = list.map((item) => item.id); + let index = 0; + while (existingIds.includes(index.toString())) { + index += 1; + } + return index.toString(); +}; + +export const generateFragmentFromTailwind = async ( + fragment: WebstudioFragment +): Promise => { + // lazily create breakpoint + let breakpoints = fragment.breakpoints; + const getBreakpointId = (mediaQuery: undefined | string) => { + if (mediaQuery === undefined) { + let baseBreakpoint = breakpoints.find(isBaseBreakpoint); + if (baseBreakpoint === undefined) { + baseBreakpoint = { id: "base", label: "" }; + breakpoints = [...breakpoints]; + breakpoints.push(baseBreakpoint); + } + return baseBreakpoint.id; + } + const parsedMediaQuery = parseMediaQuery(mediaQuery); + // unknown breakpoint + if (parsedMediaQuery === undefined) { + return; + } + let breakpoint = breakpoints.find( + (item) => + item.minWidth === parsedMediaQuery.minWidth && + item.maxWidth === parsedMediaQuery.maxWidth + ); + if (breakpoint === undefined) { + const label = `${parsedMediaQuery.minWidth ?? parsedMediaQuery.maxWidth}`; + breakpoint = { + // make sure new breakpoint id is not conflicted with already defined by fragment + id: getUniqueIdForList(breakpoints), + label, + ...parsedMediaQuery, + }; + breakpoints = [...breakpoints]; + breakpoints.push(breakpoint); + } + return breakpoint.id; + }; + + const styleSourceSelections = new Map( + fragment.styleSourceSelections.map((item) => [item.instanceId, item]) + ); + const styleSources = new Map( + fragment.styleSources.map((item) => [item.id, item]) + ); + const styles = new Map( + fragment.styles.map((item) => [getStyleDeclKey(item), item]) + ); + const getLocalStyleSource = (instanceId: Instance["id"]) => { + const styleSourceSelection = styleSourceSelections.get(instanceId); + const lastStyleSourceId = styleSourceSelection?.values.at(-1); + const lastStyleSource = styleSources.get(lastStyleSourceId ?? ""); + return lastStyleSource?.type === "local" ? lastStyleSource : undefined; + }; + const createLocalStyleSource = (instanceId: Instance["id"]) => { + const localStyleSource = { + type: "local" as const, + id: `${instanceId}:ws:style`, + }; + let styleSourceSelection = structuredClone( + styleSourceSelections.get(instanceId) + ); + if (styleSourceSelection === undefined) { + styleSourceSelection = { instanceId: instanceId, values: [] }; + styleSourceSelections.set(instanceId, styleSourceSelection); + } + styleSources.set(localStyleSource.id, localStyleSource); + styleSourceSelection.values.push(localStyleSource.id); + return localStyleSource; + }; + const createOrMergeLocalStyles = ( + instanceId: Instance["id"], + newStyles: Omit[] + ) => { + const localStyleSource = + getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId); + for (const parsedStyleDecl of newStyles) { + const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint); + // ignore unknown breakpoints + if (breakpointId === undefined) { + continue; + } + const styleDecl = { + breakpointId, + styleSourceId: localStyleSource.id, + state: parsedStyleDecl.state, + property: camelCaseProperty(parsedStyleDecl.property), + value: parsedStyleDecl.value, + }; + const styleDeckKey = getStyleDeclKey(styleDecl); + styles.delete(styleDeckKey); + styles.set(styleDeckKey, styleDecl); + } + }; + + for (const instance of fragment.instances) { + const tag = instance.tag; + if (tag && preflight[tag]) { + createOrMergeLocalStyles(instance.id, preflight[tag]); + } + } + + const props: Prop[] = []; + await Promise.all( + fragment.props.map(async (prop) => { + if (prop.name === "class" && prop.type === "string") { + const { newClasses, parsedStyles } = await parseTailwindClasses( + prop.value + ); + if (parsedStyles.length > 0) { + createOrMergeLocalStyles(prop.instanceId, parsedStyles); + if (newClasses.length > 0) { + props.push({ ...prop, value: newClasses }); + } + } + return; + } + props.push(prop); + }) + ); + + return { + ...fragment, + props, + breakpoints, + styleSources: Array.from(styleSources.values()), + styleSourceSelections: Array.from(styleSourceSelections.values()), + styles: Array.from(styles.values()), + }; +}; diff --git a/apps/builder/package.json b/apps/builder/package.json index d7cd58867742..6dc3d55362a9 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -9,6 +9,7 @@ "build": "remix vite:build", "css-to-ws": "NODE_OPTIONS=--conditions=webstudio css-to-ws", "build:webflow-presets": "pnpm css-to-ws ./app/shared/copy-paste/plugin-webflow/style-presets.css ./app/shared/copy-paste/plugin-webflow/__generated__/style-presets.ts", + "build:tailwind-preflight": "tsx --conditions=webstudio ./app/shared/tailwind/preflight-bin.ts && prettier --write ./app/shared/tailwind/__generated__/preflight.ts", "start": "remix-serve build/server/index.js", "build:http-client": "pnpm --filter=@webstudio-is/http-client build", "dev": "pnpm build:http-client && remix vite:dev", @@ -54,6 +55,9 @@ "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", "@tsndr/cloudflare-worker-jwt": "^2.5.3", + "@unocss/core": "66.1.2", + "@unocss/preset-legacy-compat": "66.1.2", + "@unocss/preset-wind3": "66.1.2", "@vercel/remix": "2.15.3", "@webstudio-is/ai": "workspace:*", "@webstudio-is/asset-uploader": "workspace:*", diff --git a/packages/css-data/package.json b/packages/css-data/package.json index a1cdd454cc94..a95da3ba2866 100644 --- a/packages/css-data/package.json +++ b/packages/css-data/package.json @@ -33,9 +33,9 @@ "private": true, "sideEffects": false, "dependencies": { - "@unocss/core": "66.1.0-beta.7", - "@unocss/preset-legacy-compat": "66.1.0-beta.7", - "@unocss/preset-wind3": "66.1.0-beta.7", + "@unocss/core": "66.1.2", + "@unocss/preset-legacy-compat": "66.1.2", + "@unocss/preset-wind3": "66.1.2", "@webstudio-is/css-engine": "workspace:*", "change-case": "^5.4.4", "colord": "^2.9.3", diff --git a/packages/css-data/src/parse-css-value.test.ts b/packages/css-data/src/parse-css-value.test.ts index b0d9ac9981fd..da1f36f3f66a 100644 --- a/packages/css-data/src/parse-css-value.test.ts +++ b/packages/css-data/src/parse-css-value.test.ts @@ -628,6 +628,13 @@ test("support custom properties var reference in custom property", () => { }); }); +test("parse empty custom property as empty unparsed", () => { + expect(parseCssValue("--inset", "")).toEqual({ + type: "unparsed", + value: "", + }); +}); + test("parse single var in repeated value without layers or tuples", () => { expect(parseCssValue("background-image", "var(--gradient)")).toEqual({ type: "var", diff --git a/packages/css-data/src/parse-css-value.ts b/packages/css-data/src/parse-css-value.ts index ec47bd061871..7f5b4af8e856 100644 --- a/packages/css-data/src/parse-css-value.ts +++ b/packages/css-data/src/parse-css-value.ts @@ -413,6 +413,11 @@ export const parseCssValue = ( } as const; if (input.length === 0) { + // custom properties can be empty + // in case interpolated value need to be avoided + if (property.startsWith("--")) { + return { type: "unparsed", value: "" }; + } return invalidValue; } diff --git a/packages/css-data/src/parse-css.test.ts b/packages/css-data/src/parse-css.test.ts index bac3717e828c..f9191ffa2e46 100644 --- a/packages/css-data/src/parse-css.test.ts +++ b/packages/css-data/src/parse-css.test.ts @@ -417,6 +417,16 @@ describe("Parse CSS", () => { ]); }); + test("parse empty custom property", () => { + expect(parseCss(`a { --my-property: ; }`)).toEqual([ + { + selector: "a", + property: "--my-property", + value: { type: "unparsed", value: "" }, + }, + ]); + }); + test("parse variable as var value", () => { expect( parseCss( diff --git a/packages/css-data/src/parse-css.ts b/packages/css-data/src/parse-css.ts index 4ab42950b4a4..ce6a0a0692c7 100644 --- a/packages/css-data/src/parse-css.ts +++ b/packages/css-data/src/parse-css.ts @@ -75,13 +75,6 @@ const parseCssValue = (property: CssProperty, value: string) => { const expanded = new Map(expandShorthands([[property, value]])); const final = new Map(); for (const [property, value] of expanded) { - if (value === "") { - // Keep the browser behavior when property is defined with an empty value e.g. `color:;` - // It may override some existing value and effectively set it to "unset"; - final.set(property, { type: "keyword", value: "unset" }); - continue; - } - final.set(property, parseCssValueLonghand(property, value)); } return final; diff --git a/packages/css-data/src/shorthands.test.ts b/packages/css-data/src/shorthands.test.ts index 829faa837029..7c94b976c80c 100644 --- a/packages/css-data/src/shorthands.test.ts +++ b/packages/css-data/src/shorthands.test.ts @@ -718,10 +718,20 @@ test("expand list-style", () => { ["list-style-type", "lower-roman"], ]); expect(expandShorthands([["list-style", `square`]])).toEqual([ - ["list-style-position", "initial"], - ["list-style-image", "initial"], + ["list-style-position", "outside"], + ["list-style-image", "none"], ["list-style-type", "square"], ]); + expect(expandShorthands([["list-style", `inside`]])).toEqual([ + ["list-style-position", "inside"], + ["list-style-image", "none"], + ["list-style-type", "disc"], + ]); + expect(expandShorthands([["list-style", `none`]])).toEqual([ + ["list-style-position", "outside"], + ["list-style-image", "none"], + ["list-style-type", "none"], + ]); }); test("expand animation", () => { diff --git a/packages/css-data/src/shorthands.ts b/packages/css-data/src/shorthands.ts index 25f9d3c23791..506909d51cbc 100644 --- a/packages/css-data/src/shorthands.ts +++ b/packages/css-data/src/shorthands.ts @@ -1244,9 +1244,31 @@ const expandShorthand = function* (property: string, value: CssNode) { ], value ); - yield ["list-style-position", position ?? createInitialNode()] as const; - yield ["list-style-image", image ?? createInitialNode()] as const; - yield ["list-style-type", type ?? createInitialNode()] as const; + // Using a value of none in the shorthand is potentially ambiguous, + // as none is a valid value for both list-style-image and list-style-type. + // To resolve this ambiguity, a value of none in the shorthand must be applied + // to whichever of the two properties aren’t otherwise set by the shorthand. + let imageValue = image ? generate(image) : undefined; + let typeValue = type ? generate(type) : undefined; + if ( + (imageValue ?? typeValue) === "none" && + (typeValue ?? imageValue) === "none" + ) { + imageValue = imageValue ?? typeValue; + typeValue = imageValue ?? typeValue; + } + yield [ + "list-style-position", + position ?? createIdentifier("outside"), + ] as const; + yield [ + "list-style-image", + image ?? createIdentifier(imageValue ?? "none"), + ] as const; + yield [ + "list-style-type", + type ?? createIdentifier(typeValue ?? "disc"), + ] as const; break; } @@ -1390,7 +1412,12 @@ const expandShorthand = function* (property: string, value: CssNode) { const parseValue = function* (property: string, value: string) { try { const ast = parse(value, { context: "value" }); - if (ast.type === "Value" && ast.children.isEmpty) { + if ( + // custom properties can be empty + !property.startsWith("--") && + ast.type === "Value" && + ast.children.isEmpty + ) { ast.children.appendData({ type: "Identifier", name: "unset" }); } yield [property, ast] as const; diff --git a/packages/template/src/css.ts b/packages/template/src/css.ts index 93858f475f61..0b02d360632f 100644 --- a/packages/template/src/css.ts +++ b/packages/template/src/css.ts @@ -2,6 +2,7 @@ import { parseCss } from "@webstudio-is/css-data"; import type { CssProperty, StyleValue } from "@webstudio-is/css-engine"; export type TemplateStyleDecl = { + breakpoint?: string; state?: string; property: CssProperty; value: StyleValue; @@ -13,8 +14,8 @@ export const css = ( ): TemplateStyleDecl[] => { const cssString = `.styles{ ${String.raw({ raw: strings }, ...values)} }`; const styles: TemplateStyleDecl[] = []; - for (const { state, property, value } of parseCss(cssString)) { - styles.push({ state, property: property, value }); + for (const { breakpoint, state, property, value } of parseCss(cssString)) { + styles.push({ breakpoint, state, property: property, value }); } return styles; }; diff --git a/packages/template/src/jsx.test.tsx b/packages/template/src/jsx.test.tsx index 9916ec2a388f..1ea217f940ac 100644 --- a/packages/template/src/jsx.test.tsx +++ b/packages/template/src/jsx.test.tsx @@ -280,6 +280,42 @@ test("avoid generating style data without styles", () => { expect(styles).toEqual([]); }); +test("generate breakpoints", () => { + const { breakpoints, styleSources, styleSourceSelections, styles } = + renderTemplate( + <$.Body + ws:style={css` + color: red; + @media (min-width: 1024px) { + color: blue; + } + `} + > + ); + expect(breakpoints).toEqual([ + { id: "base", label: "" }, + { id: "0", label: "1024", minWidth: 1024 }, + ]); + expect(styleSources).toEqual([{ id: "0:ws:style", type: "local" }]); + expect(styleSourceSelections).toEqual([ + { instanceId: "0", values: ["0:ws:style"] }, + ]); + expect(styles).toEqual([ + { + breakpointId: "base", + styleSourceId: "0:ws:style", + property: "color", + value: { type: "keyword", value: "red" }, + }, + { + breakpointId: "0", + styleSourceId: "0:ws:style", + property: "color", + value: { type: "keyword", value: "blue" }, + }, + ]); +}); + test("render variable used in prop expression", () => { const count = new Variable("count", 1); const { props, dataSources } = renderTemplate( diff --git a/packages/template/src/jsx.ts b/packages/template/src/jsx.ts index ba6d100c3bae..9a259be41de8 100644 --- a/packages/template/src/jsx.ts +++ b/packages/template/src/jsx.ts @@ -14,7 +14,7 @@ import type { } from "@webstudio-is/sdk"; import { showAttribute } from "@webstudio-is/react-sdk"; import type { TemplateStyleDecl } from "./css"; -import { camelCaseProperty } from "@webstudio-is/css-data"; +import { camelCaseProperty, parseMediaQuery } from "@webstudio-is/css-data"; export class Variable { name: string; @@ -124,7 +124,6 @@ export const renderTemplate = ( root: JSX.Element, generateId?: () => string ): WebstudioFragment => { - let lastId = -1; const instances: Instance[] = []; const props: Prop[] = []; const breakpoints: Breakpoint[] = []; @@ -133,13 +132,23 @@ export const renderTemplate = ( const styles: StyleDecl[] = []; const dataSources = new Map(); const resources = new Map(); - const ids = new Map(); - const getId = (key: unknown) => { - let id = ids.get(key); + const idsByKey = new Map(); + const lastIdsByList = new Map(); + // ensure ids are stable for specific list + const getIdForList = (list: unknown) => { + if (generateId) { + return generateId(); + } + let lastId = lastIdsByList.get(list) ?? -1; + lastId += 1; + lastIdsByList.set(list, lastId); + return lastId.toString(); + }; + const getIdByKey = (key: unknown) => { + let id = idsByKey.get(key); if (id === undefined) { - lastId += 1; - id = generateId?.() ?? lastId.toString(); - ids.set(key, id); + id = getIdForList(idsByKey); + idsByKey.set(key, id); } return id; }; @@ -147,7 +156,7 @@ export const renderTemplate = ( instanceId: string, variable: Variable | Parameter ) => { - const id = getId(variable); + const id = getIdByKey(variable); if (dataSources.has(variable)) { return id; } @@ -199,7 +208,7 @@ export const renderTemplate = ( ); }; const getResourceId = (instanceId: string, resourceValue: ResourceValue) => { - const id = `resource:${getId(resourceValue)}`; + const id = `resource:${getIdByKey(resourceValue)}`; if (resources.has(resourceValue)) { return id; } @@ -219,21 +228,42 @@ export const renderTemplate = ( return id; }; // lazily create breakpoint - const getBreakpointId = () => { - if (breakpoints.length > 0) { - return breakpoints[0].id; + const getBreakpointId = (mediaQuery: undefined | string) => { + if (mediaQuery === undefined) { + let baseBreakpoint = breakpoints.find( + (item) => item.minWidth === undefined && item.maxWidth === undefined + ); + if (baseBreakpoint === undefined) { + baseBreakpoint = { id: "base", label: "" }; + breakpoints.push(baseBreakpoint); + } + return baseBreakpoint.id; } - const breakpointId = "base"; - breakpoints.push({ - id: breakpointId, - label: "", - }); - return breakpointId; + const parsedMediaQuery = parseMediaQuery(mediaQuery); + if (parsedMediaQuery === undefined) { + return; + } + let breakpoint = breakpoints.find( + (item) => + item.minWidth === parsedMediaQuery.minWidth && + item.maxWidth === parsedMediaQuery.maxWidth + ); + if (breakpoint === undefined) { + const id = getIdForList(breakpoints); + const label = `${parsedMediaQuery.minWidth ?? parsedMediaQuery.maxWidth}`; + breakpoint = { id, label, ...parsedMediaQuery }; + breakpoints.push(breakpoint); + } + return breakpoint.id; }; + const localStylesByInstanceId = new Map< + Instance["id"], + TemplateStyleDecl[] + >(); const convertElementToInstance = ( element: JSX.Element ): Instance["children"][number] => { - const instanceId = element.props?.["ws:id"] ?? getId(element); + const instanceId = element.props?.["ws:id"] ?? getIdByKey(element); let tag: string | undefined; for (const entry of Object.entries({ ...element.props })) { const [_name, value] = entry; @@ -246,25 +276,9 @@ export const renderTemplate = ( continue; } if (name === "ws:style") { - const styleSourceId = `${instanceId}:${name}`; - styleSources.push({ - type: "local", - id: styleSourceId, - }); - styleSourceSelections.push({ - instanceId, - values: [styleSourceId], - }); const localStyles = value as TemplateStyleDecl[]; - for (const { state, property, value } of localStyles) { - styles.push({ - breakpointId: getBreakpointId(), - styleSourceId, - state, - property: camelCaseProperty(property), - value, - }); - } + // create styles with breakpoints later to ensure more stable ids + localStylesByInstanceId.set(instanceId, localStyles); continue; } if (name === "ws:show") { @@ -363,6 +377,30 @@ export const renderTemplate = ( } else { children.push(convertElementToInstance(root)); } + for (const [instanceId, localStyles] of localStylesByInstanceId) { + const styleSourceId = `${instanceId}:ws:style`; + styleSources.push({ + type: "local", + id: styleSourceId, + }); + styleSourceSelections.push({ + instanceId, + values: [styleSourceId], + }); + for (const { breakpoint, state, property, value } of localStyles) { + const breakpointId = getBreakpointId(breakpoint); + if (breakpointId === undefined) { + continue; + } + styles.push({ + breakpointId, + styleSourceId, + state, + property: camelCaseProperty(property), + value, + }); + } + } return { children, instances, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe9a0dfacc67..8bb0be8fc4b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,15 @@ importers: '@tsndr/cloudflare-worker-jwt': specifier: ^2.5.3 version: 2.5.3 + '@unocss/core': + specifier: 66.1.2 + version: 66.1.2 + '@unocss/preset-legacy-compat': + specifier: 66.1.2 + version: 66.1.2 + '@unocss/preset-wind3': + specifier: 66.1.2 + version: 66.1.2 '@vercel/remix': specifier: 2.15.3 version: 2.15.3(@remix-run/dev@2.16.5(patch_hash=yortwzoeu3uj2blmdikhhw5byy)(@remix-run/react@2.16.5(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)(typescript@5.8.2))(@remix-run/serve@2.16.5(typescript@5.8.2))(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3)(typescript@5.8.2)(vite@6.3.4(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3))(wrangler@3.63.2(@cloudflare/workers-types@4.20240701.0)))(@remix-run/node@2.16.5(typescript@5.8.2))(@remix-run/server-runtime@2.16.5(typescript@5.8.2))(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318) @@ -1269,14 +1278,14 @@ importers: packages/css-data: dependencies: '@unocss/core': - specifier: 66.1.0-beta.7 - version: 66.1.0-beta.7 + specifier: 66.1.2 + version: 66.1.2 '@unocss/preset-legacy-compat': - specifier: 66.1.0-beta.7 - version: 66.1.0-beta.7 + specifier: 66.1.2 + version: 66.1.2 '@unocss/preset-wind3': - specifier: 66.1.0-beta.7 - version: 66.1.0-beta.7 + specifier: 66.1.2 + version: 66.1.2 '@webstudio-is/css-engine': specifier: workspace:* version: link:../css-engine @@ -5573,23 +5582,23 @@ packages: resolution: {integrity: sha512-AjOC3zfnxd6S4Eiy3jwktJPclqhFHNyd8L6Gycf9WUPoKZpgM5PjkxY1X7uSy61xVpiJDhhk7XT2NVsN3ALTWg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@unocss/core@66.1.0-beta.7': - resolution: {integrity: sha512-l1/r+Jd9TbsRqR/geEdIV/Erzvs26GitTtMVsGcJfuaK1/WWOLtbSHRUDQAB/UpcOOWvuNuAv4UWsXX9Z0DFmw==} + '@unocss/core@66.1.2': + resolution: {integrity: sha512-mN9h1hHEuhDcdbI4z74o7UnxlBZYVsJpYcdC1YLWBKROcLYTkuyZ7hgBzpo1FBNox2Bt3JnrSinVDmc44Bxjow==} - '@unocss/extractor-arbitrary-variants@66.1.0-beta.7': - resolution: {integrity: sha512-LD8W9PlpHnFmuynI+TJzdE5z9OKY/tVaagY/Ak1mICFEWveH3jFuN13KH2jaI3/V2KaTwkcY/8tGQJXv8dKWAw==} + '@unocss/extractor-arbitrary-variants@66.1.2': + resolution: {integrity: sha512-F570wH9VYeFTb4r8qgcbN5QpEVIAvFC1zOnrAPUr6B6kbU2YChMXxHP7PHK0AzLHnEr458Pwpzl6hmP6bzxZ8g==} - '@unocss/preset-legacy-compat@66.1.0-beta.7': - resolution: {integrity: sha512-AZBC95jFCyiJetxFa/GfM+FCVIrNT6Kd8mVBiighSQfhUEmfTLhTen41/tUrCtUtxpbwgyxRmmkSUZjvsymodw==} + '@unocss/preset-legacy-compat@66.1.2': + resolution: {integrity: sha512-s1SnCXm9ywmp0plWQsXr8APxRhQEeblF7E6UmanFMj012/DU0bxWVUafnXBbeYUjtfBKCFlDvbW2pm30UGNQOw==} - '@unocss/preset-mini@66.1.0-beta.7': - resolution: {integrity: sha512-5v9RNFTk2OMLbE45JVoYA0HtZKCDCI3j7uRAcuRLVP3O/yAd9JlP/b8ou3wvtgxHTXFEgk6Pt8dFDKPadA3Hrw==} + '@unocss/preset-mini@66.1.2': + resolution: {integrity: sha512-oiDe+VhwZ8B5Z0UGfggtOwgpRZMLtH1RTDFvmJmJEXYYX5BPWknS6wYcQzxy0i/y9ym0xp2QnEaTpGmR7LKdkg==} - '@unocss/preset-wind3@66.1.0-beta.7': - resolution: {integrity: sha512-JgiHl2L0J6VdmowGk45WB9NLYxO1tTQpr6GspMyhMz63pkcqjJtP5g8JfeIw5G0uwfWhoGubqG3RxKKaWHsoyg==} + '@unocss/preset-wind3@66.1.2': + resolution: {integrity: sha512-S09imGOngAAOXCBCHb3JAtxD1/L7nDWrgEeX6NT0ElDp3X1T6XxUXYJlpjCfcqV/klMoXyYouKvp0YuG9QSgVg==} - '@unocss/rule-utils@66.1.0-beta.7': - resolution: {integrity: sha512-oJ5lcHRgN1aabsszkBXoiYp0G6LLM011BJoAEfKOeRZ14FGFlg0zuOi/h7aKlVuIvBt6q8BWosJLlZSAQnNypg==} + '@unocss/rule-utils@66.1.2': + resolution: {integrity: sha512-nn0ehvDh7yyWq2mcBDLVpmMAivjRATUroZ8ETinyN1rmfsGesm71R0d1gV3K+Z6YC7a3+dMLc+/qzI7VK3AG/Q==} engines: {node: '>=14'} '@vanilla-extract/babel-plugin-debug-ids@1.0.6': @@ -13112,31 +13121,31 @@ snapshots: '@typescript-eslint/types': 8.26.1 eslint-visitor-keys: 4.2.0 - '@unocss/core@66.1.0-beta.7': {} + '@unocss/core@66.1.2': {} - '@unocss/extractor-arbitrary-variants@66.1.0-beta.7': + '@unocss/extractor-arbitrary-variants@66.1.2': dependencies: - '@unocss/core': 66.1.0-beta.7 + '@unocss/core': 66.1.2 - '@unocss/preset-legacy-compat@66.1.0-beta.7': + '@unocss/preset-legacy-compat@66.1.2': dependencies: - '@unocss/core': 66.1.0-beta.7 + '@unocss/core': 66.1.2 - '@unocss/preset-mini@66.1.0-beta.7': + '@unocss/preset-mini@66.1.2': dependencies: - '@unocss/core': 66.1.0-beta.7 - '@unocss/extractor-arbitrary-variants': 66.1.0-beta.7 - '@unocss/rule-utils': 66.1.0-beta.7 + '@unocss/core': 66.1.2 + '@unocss/extractor-arbitrary-variants': 66.1.2 + '@unocss/rule-utils': 66.1.2 - '@unocss/preset-wind3@66.1.0-beta.7': + '@unocss/preset-wind3@66.1.2': dependencies: - '@unocss/core': 66.1.0-beta.7 - '@unocss/preset-mini': 66.1.0-beta.7 - '@unocss/rule-utils': 66.1.0-beta.7 + '@unocss/core': 66.1.2 + '@unocss/preset-mini': 66.1.2 + '@unocss/rule-utils': 66.1.2 - '@unocss/rule-utils@66.1.0-beta.7': + '@unocss/rule-utils@66.1.2': dependencies: - '@unocss/core': 66.1.0-beta.7 + '@unocss/core': 66.1.2 magic-string: 0.30.17 '@vanilla-extract/babel-plugin-debug-ids@1.0.6': From f3f4ee88cf9ec867ba7f0ddc81b37fe8bc1f20af Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 18 May 2025 00:46:58 +0300 Subject: [PATCH 2/4] Fix lint --- apps/builder/app/builder/shared/commands.ts | 2 +- apps/builder/app/shared/tailwind/tailwind.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/builder/app/builder/shared/commands.ts b/apps/builder/app/builder/shared/commands.ts index 3aa0517ff8d2..1d2d72883c43 100644 --- a/apps/builder/app/builder/shared/commands.ts +++ b/apps/builder/app/builder/shared/commands.ts @@ -525,7 +525,7 @@ export const { emitCommand, subscribeCommands } = createCommandsEmitter({ { name: "pasteHtmlWithTailwindClasses", handler: async () => { - let html = await navigator.clipboard.readText(); + const html = await navigator.clipboard.readText(); let fragment = generateFragmentFromHtml(html); fragment = await generateFragmentFromTailwind(fragment); return insertWebstudioFragmentAt(fragment); diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts index e4d3e1d43602..7fa78f02405e 100644 --- a/apps/builder/app/shared/tailwind/tailwind.ts +++ b/apps/builder/app/shared/tailwind/tailwind.ts @@ -36,7 +36,7 @@ const parseTailwindClasses = async (classes: string) => { // to prevent bloating css with preflights from previous calls const generator = await createUnoGenerator(); const generated = await generator.generate(classes); - let css = generated.css; + const css = generated.css; let parsedStyles: Omit[] = []; // @todo probably builtin in v4 if (css.includes("border")) { From 9e4f015b2c87aad7086cb380b697a70db69a4b0c Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Sun, 18 May 2025 18:52:56 +0300 Subject: [PATCH 3/4] Trigger rebuild From 53c69acf78cf4541d847c528854594d7a60fc744 Mon Sep 17 00:00:00 2001 From: Bogdan Chadkin Date: Fri, 23 May 2025 12:26:31 +0300 Subject: [PATCH 4/4] Replace lobotomized owl spacing with gaps --- .../app/shared/tailwind/tailwind.test.tsx | 70 +++++++++++++++++++ apps/builder/app/shared/tailwind/tailwind.ts | 38 ++++++++++ 2 files changed, 108 insertions(+) diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx index c1ebda7d3b48..f944abe962d9 100644 --- a/apps/builder/app/shared/tailwind/tailwind.test.tsx +++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx @@ -320,3 +320,73 @@ test("merge tailwind breakpoints with already defined ones", async () => { ) ); }); + +test("generate space without display property", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + <> + + + + ) + ) + ).toEqual( + renderTemplate( + <> + + + + ) + ); +}); + +test("generate space with display property", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + <> + + + + ) + ) + ).toEqual( + renderTemplate( + <> + + + + ) + ); +}); diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts index 7fa78f02405e..7105098a3dfd 100644 --- a/apps/builder/app/shared/tailwind/tailwind.ts +++ b/apps/builder/app/shared/tailwind/tailwind.ts @@ -35,6 +35,30 @@ const parseTailwindClasses = async (classes: string) => { // avoid caching uno generator instance // to prevent bloating css with preflights from previous calls const generator = await createUnoGenerator(); + let hasColumnGaps = false; + let hasRowGaps = false; + let hasFlexOrGrid = false; + classes = classes + .split(" ") + .map((item) => { + // styles data cannot express space-x and space-y selectors + // with lobotomized owl so replace with gaps + const spaceX = "space-x-"; + if (item.startsWith(spaceX)) { + hasColumnGaps = true; + return `gap-x-${item.slice(spaceX.length)}`; + } + const spaceY = "space-y-"; + if (item.startsWith(spaceY)) { + hasRowGaps = true; + return `gap-y-${item.slice(spaceY.length)}`; + } + if (item.endsWith("flex") || item.endsWith("grid")) { + hasFlexOrGrid = true; + } + return item; + }) + .join(" "); const generated = await generator.generate(classes); const css = generated.css; let parsedStyles: Omit[] = []; @@ -54,6 +78,20 @@ const parseTailwindClasses = async (classes: string) => { parsedStyles = parsedStyles.filter( (styleDecl) => !styleDecl.state?.startsWith("::") ); + // gaps work only with flex and grid + // so try to use one or another for different axes + if (hasColumnGaps && !hasFlexOrGrid) { + parsedStyles.unshift({ + property: "display", + value: { type: "keyword", value: "flex" }, + }); + } + if (hasRowGaps && !hasFlexOrGrid) { + parsedStyles.unshift({ + property: "display", + value: { type: "keyword", value: "grid" }, + }); + } const newClasses = classes .split(" ") .filter((item) => !generated.matched.has(item))