diff --git a/apps/builder/app/builder/features/topbar/domains.tsx b/apps/builder/app/builder/features/topbar/domains.tsx index a84f62f49724..b94b548c4aad 100644 --- a/apps/builder/app/builder/features/topbar/domains.tsx +++ b/apps/builder/app/builder/features/topbar/domains.tsx @@ -348,7 +348,7 @@ const DomainItem = ({ + {requestUpgrade && ( + + + Please upgrade to the Pro plan or higher to use automatic domain + configuration. + + + + + Upgrade to Pro + + + + )} ); }; diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 0f54fa50036c..82771f6c6053 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -334,3 +334,23 @@ test("generate Image component instead of img element", () => { ) ); }); + +test("strip unsupported attribute names", () => { + expect( + generateFragmentFromHtml(` + + + `) + ).toEqual( + renderTemplate( + <> + Expand + + Toggle + + + ) + ); +}); diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index c7809f1a44ae..3c4031a0f0ce 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -18,6 +18,7 @@ import { ariaAttributes, attributesByTag } from "@webstudio-is/html-data"; import { camelCaseProperty, parseCss } from "@webstudio-is/css-data"; import { richTextContentTags } from "./content-model"; import { setIsSubsetOf } from "./shim"; +import { isAttributeNameSafe } from "@webstudio-is/react-sdk"; type ElementNode = DefaultTreeAdapterMap["element"]; @@ -169,6 +170,10 @@ export const generateFragmentFromHtml = ( } instances.set(instance.id, instance); for (const attr of node.attrs) { + // skip attributes which cannot be rendered in jsx + if (!isAttributeNameSafe(attr.name)) { + continue; + } const id = `${instance.id}:${attr.name}`; const instanceId = instance.id; const name = attr.name; diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx index 19cc8e3df0c8..02cb38c962d5 100644 --- a/apps/builder/app/shared/tailwind/tailwind.test.tsx +++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx @@ -114,8 +114,8 @@ test("override border opacity", async () => { ws:tag="div" ws:style={css` border-style: solid; - border-width: 1px; border-color: rgb(229 231 235 / var(--tw-border-opacity)); + border-width: 1px; --tw-border-opacity: 0.6; `} > @@ -315,31 +315,6 @@ describe("extract breakpoints", () => { ); }); - test("base is first breakpoint", async () => { - expect( - await generateFragmentFromTailwind( - renderTemplate( - - ) - ) - ).toEqual( - renderTemplate( - - ) - ); - }); - test("base is last breakpoint", async () => { expect( await generateFragmentFromTailwind( @@ -393,27 +368,6 @@ describe("extract breakpoints", () => { ); }); - test("preserve breakpoint when no base breakpoint", async () => { - expect( - await generateFragmentFromTailwind( - renderTemplate( - - ) - ) - ).toEqual( - renderTemplate( - - ) - ); - }); - test("extract container class", async () => { expect( await generateFragmentFromTailwind( @@ -427,7 +381,6 @@ describe("extract breakpoints", () => { @media (max-width: 479px) { max-width: none; } - width: 100%; @media (max-width: 767px) { max-width: 640px; } @@ -441,6 +394,7 @@ describe("extract breakpoints", () => { @media (min-width: 1440px) { max-width: 1536px; } + width: 100%; `} > ) @@ -501,11 +455,123 @@ describe("extract breakpoints", () => { ws:tag="div" ws:style={css` opacity: 0.1; - @media (min-width: 480px) { + @media (max-width: 479px) { &:hover { - opacity: 0.2; + opacity: unset; } } + &:hover { + opacity: 0.2; + } + `} + > + ) + ); + }); + + test("adapt max-* breakpoints", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); + }); + + test("ignore composite breakpoints", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); + }); + + test("use unset for missing base breakpoint 1", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + + ) + ); + }); + + test("use unset for missing base breakpoint 2", async () => { + expect( + await generateFragmentFromTailwind( + renderTemplate( + + ) + ) + ).toEqual( + renderTemplate( + ) @@ -518,8 +584,8 @@ test("generate space without display property", async () => { await generateFragmentFromTailwind( renderTemplate( <> - - + + ) ) @@ -530,7 +596,10 @@ test("generate space without display property", async () => { ws:tag="div" ws:style={css` display: flex; - column-gap: 1rem; + @media (max-width: 767px) { + column-gap: 1rem; + } + column-gap: 1.5rem; `} > { display: flex; flex-direction: column; align-items: start; - row-gap: 1rem; + @media (max-width: 767px) { + row-gap: 1rem; + } + row-gap: 1.5rem; `} > @@ -576,8 +648,8 @@ test("generate space with display property", async () => { @media (max-width: 767px) { display: none; } - row-gap: 1rem; display: flex; + row-gap: 1rem; `} > diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts index 2844faa68c42..5ffa3d9a37c9 100644 --- a/apps/builder/app/shared/tailwind/tailwind.ts +++ b/apps/builder/app/shared/tailwind/tailwind.ts @@ -27,31 +27,35 @@ const availableBreakpoints = [ ]; const tailwindToWebstudioMappings: Record = { - 639: 479, + 639.9: 479, 640: 480, - 1023: 991, + 767.9: 767, + 1023.9: 991, 1024: 992, - 1535: 1439, + 1279.9: 1279, + 1535.9: 1439, 1536: 1440, }; +type StyleDecl = Omit; + type Breakpoint = { - key: string; + styleDecl: StyleDecl; minWidth?: number; maxWidth?: number; }; type Range = { - key: string; + styleDecl: StyleDecl; start: number; end: number; }; const serializeBreakpoint = (breakpoint: Breakpoint) => { - if (breakpoint?.minWidth) { + if (breakpoint?.minWidth !== undefined) { return `(min-width: ${breakpoint.minWidth}px)`; } - if (breakpoint?.maxWidth) { + if (breakpoint?.maxWidth !== undefined) { return `(max-width: ${breakpoint.maxWidth}px)`; } }; @@ -61,17 +65,17 @@ const UPPER_BOUND = Number.MAX_SAFE_INTEGER; const breakpointsToRanges = (breakpoints: Breakpoint[]) => { // collect lower bounds and ids const values = new Set([0]); - const keys = new Map(); + const styles = new Map(); for (const breakpoint of breakpoints) { if (breakpoint.minWidth !== undefined) { values.add(breakpoint.minWidth); - keys.set(breakpoint.minWidth, breakpoint.key); + styles.set(breakpoint.minWidth, breakpoint.styleDecl); } else if (breakpoint.maxWidth !== undefined) { values.add(breakpoint.maxWidth + 1); - keys.set(breakpoint.maxWidth, breakpoint.key); + styles.set(breakpoint.maxWidth, breakpoint.styleDecl); } else { // base breakpoint - keys.set(undefined, breakpoint.key); + styles.set(undefined, breakpoint.styleDecl); } } const sortedValues = Array.from(values).sort((left, right) => left - right); @@ -84,9 +88,24 @@ const breakpointsToRanges = (breakpoints: Breakpoint[]) => { } else { end = sortedValues[index + 1] - 1; } - const key = keys.get(start) ?? keys.get(end) ?? keys.get(undefined); - if (key) { - ranges.push({ key, start, end }); + const styleDecl = + styles.get(start) ?? styles.get(end) ?? styles.get(undefined); + if (styleDecl) { + ranges.push({ styleDecl, start, end }); + continue; + } + // when declaration is missing add new one with unset value + // to fill the hole in breakpoints + // for example + // "sm:opacity-20" has a hole at the start + // "max-sm:opacity-10 md:opacity-20" has a whole in the middle + const example = Array.from(styles.values())[0]; + if (example) { + const newStyleDecl: StyleDecl = { + ...example, + value: { type: "keyword", value: "unset" }, + }; + ranges.push({ styleDecl: newStyleDecl, start, end }); } } return ranges; @@ -94,32 +113,31 @@ const breakpointsToRanges = (breakpoints: Breakpoint[]) => { const rangesToBreakpoints = (ranges: Range[]) => { const breakpoints: Breakpoint[] = []; - for (const range of ranges) { + for (const { styleDecl, start, end } of ranges) { let matchedBreakpoint; for (const breakpoint of availableBreakpoints) { - if (breakpoint.minWidth === range.start) { - matchedBreakpoint = { key: range.key, minWidth: range.start }; + if (breakpoint.minWidth === start) { + matchedBreakpoint = { styleDecl, minWidth: start }; } - if (breakpoint.maxWidth === range.end) { - matchedBreakpoint = { key: range.key, maxWidth: range.end }; + if (breakpoint.maxWidth === end) { + matchedBreakpoint = { styleDecl, maxWidth: end }; } if ( breakpoint.minWidth === undefined && breakpoint.maxWidth === undefined ) { - matchedBreakpoint ??= { key: range.key }; + matchedBreakpoint ??= { styleDecl }; } } if (matchedBreakpoint) { + styleDecl.breakpoint = serializeBreakpoint(matchedBreakpoint); breakpoints.push(matchedBreakpoint); } } return breakpoints; }; -const adaptBreakpoints = ( - parsedStyles: Omit[] -) => { +const adaptBreakpoints = (parsedStyles: StyleDecl[]) => { const breakpointGroups = new Map(); for (const styleDecl of parsedStyles) { const mediaQuery = styleDecl.breakpoint @@ -139,29 +157,17 @@ const adaptBreakpoints = ( group = []; breakpointGroups.set(groupKey, group); } - const styleDeclKey = `${styleDecl.breakpoint ?? ""}:${styleDecl.property}:${styleDecl.state ?? ""}`; - group.push({ key: styleDeclKey, ...mediaQuery }); + group.push({ styleDecl, ...mediaQuery }); } - const breakpointsByKey = new Map(); - for (let group of breakpointGroups.values()) { + const newStyles: typeof parsedStyles = []; + for (const group of breakpointGroups.values()) { const ranges = breakpointsToRanges(group); - // adapt breakpoints only when first range is defined - // for example opacity-10 sm:opacity-20 will work - // but sm:opacity-20 alone does not have the base to switch to - if (ranges[0].start === 0) { - group = rangesToBreakpoints(ranges); - } - for (const breakpoint of group) { - breakpointsByKey.set(breakpoint.key, breakpoint); - } - } - for (const styleDecl of parsedStyles) { - const styleDeclKey = `${styleDecl.breakpoint ?? ""}:${styleDecl.property}:${styleDecl.state ?? ""}`; - const breakpoint = breakpointsByKey.get(styleDeclKey); - if (breakpoint) { - styleDecl.breakpoint = serializeBreakpoint(breakpoint); + const newGroup = rangesToBreakpoints(ranges); + for (const { styleDecl } of newGroup) { + newStyles.push(styleDecl); } } + return newStyles; }; const createUnoGenerator = async () => { @@ -192,15 +198,13 @@ const parseTailwindClasses = async (classes: string) => { .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)) { + if (item.includes("space-x-")) { hasColumnGaps = true; - return `gap-x-${item.slice(spaceX.length)}`; + return item.replace("space-x-", "gap-x-"); } - const spaceY = "space-y-"; - if (item.startsWith(spaceY)) { + if (item.includes("space-y-")) { hasRowGaps = true; - return `gap-y-${item.slice(spaceY.length)}`; + return item.replace("space-y-", "gap-y-"); } hasFlexOrGrid ||= item.endsWith("flex") || item.endsWith("grid"); hasContainer ||= item === "container"; @@ -210,7 +214,7 @@ const parseTailwindClasses = async (classes: string) => { const generated = await generator.generate(classes); // use tailwind prefix instead of unocss one const css = generated.css.replaceAll("--un-", "--tw-"); - let parsedStyles: Omit[] = []; + let parsedStyles: StyleDecl[] = []; // @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) @@ -259,7 +263,7 @@ const parseTailwindClasses = async (classes: string) => { } ); } - adaptBreakpoints(parsedStyles); + parsedStyles = adaptBreakpoints(parsedStyles); const newClasses = classes .split(" ") .filter((item) => !generated.matched.has(item)) @@ -348,7 +352,7 @@ export const generateFragmentFromTailwind = async ( }; const createOrMergeLocalStyles = ( instanceId: Instance["id"], - newStyles: Omit[] + newStyles: StyleDecl[] ) => { const localStyleSource = getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId); diff --git a/packages/css-data/src/parse-css.test.ts b/packages/css-data/src/parse-css.test.ts index f9191ffa2e46..50b1b00d0442 100644 --- a/packages/css-data/src/parse-css.test.ts +++ b/packages/css-data/src/parse-css.test.ts @@ -714,6 +714,30 @@ test("ignore unsupported media queries", () => { ]); }); +test("ignore nested media queries", () => { + expect( + parseCss(` + @media (min-width: 768px) { + a { + color: green; + } + @media (max-width: 1024px) { + a { + color: red; + } + } + } + `) + ).toEqual([ + { + breakpoint: "(min-width:768px)", + selector: "a", + property: "color", + value: { type: "keyword", value: "green" }, + }, + ]); +}); + test("ignore unsupported at rules", () => { expect( parseCss(` diff --git a/packages/css-data/src/parse-css.ts b/packages/css-data/src/parse-css.ts index ce6a0a0692c7..5af9cf0ed6e1 100644 --- a/packages/css-data/src/parse-css.ts +++ b/packages/css-data/src/parse-css.ts @@ -103,6 +103,12 @@ export const parseCss = (css: string): ParsedStyleDecl[] => { } csstree.walk(ast, function (node) { + // forbid nested at rules + if (node.type === "Atrule" && this.atrule) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore https://github.com/csstree/csstree/blob/v2.3.1/docs/traversal.md + return this.break; + } if (node.type !== "Declaration" || this.rule?.prelude.type === undefined) { return; } diff --git a/packages/domain/src/trpc/domain.ts b/packages/domain/src/trpc/domain.ts index 9bc73fbcfb34..8f6bee07b122 100644 --- a/packages/domain/src/trpc/domain.ts +++ b/packages/domain/src/trpc/domain.ts @@ -6,21 +6,10 @@ import { router, procedure } from "@webstudio-is/trpc-interface/index.server"; import { Templates } from "@webstudio-is/sdk"; import { db } from "../db"; -const registrars = { - cloudflare: { - rdap: "https://rdap.cloudflare.com/rdap/v1/domain/", - cnameFlattening: true, - alias: false, - }, - /* - // ALIAS record is not support by cloudflare custom domains service - namecheap: { - rdap: "https://rdap.namecheap.com/domain/", - cnameFlattening: false, - alias: true, - }, - */ -} as const; +const rdap = [ + "https://rdap.cloudflare.com/rdap/v1/domain/", + "https://rdap.namecheap.com/domain/", +]; export const domainRouter = router({ getEntriToken: procedure.query(async ({ ctx }) => { @@ -44,13 +33,13 @@ export const domainRouter = router({ .input(z.object({ domain: z.string() })) .query(async ({ input }) => { try { - for (const [name, meta] of Object.entries(registrars)) { - const response = await fetch(`${meta.rdap}${input.domain}`); + for (const rdapEndpoint of rdap) { + const response = await fetch(`${rdapEndpoint}${input.domain}`); if (response.ok) { + const data = await response.text(); return { - name: name as keyof typeof registrars, - cnameFlattening: meta.cnameFlattening, - alias: meta.alias, + // detect by nameservers rather than registrar url + cnameFlattening: data.includes(".ns.cloudflare.com"), }; } } @@ -60,7 +49,6 @@ export const domainRouter = router({ return { name: "other", cnameFlattening: false, - alias: false, }; }),