diff --git a/apps/builder/app/dashboard/dashboard.tsx b/apps/builder/app/dashboard/dashboard.tsx index aae8b8c3d9b9..65f3ac60b265 100644 --- a/apps/builder/app/dashboard/dashboard.tsx +++ b/apps/builder/app/dashboard/dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useLayoutEffect, useState, type ReactNode } from "react"; import { Box, Flex, @@ -162,17 +162,21 @@ const $data = atom(); export const DashboardSetup = ({ data }: { data: DashboardData }) => { const revalidator = useRevalidator(); const revalidateVersion = useStore($shouldRevalidateProjects); - useEffect(() => { + // Apply user-scoped loader data before paint so stale account data is never visible. + useLayoutEffect(() => { $data.set(data); setSharedStores(data); $workspaces.set(data.workspaces); $user.set(data.user); - }, [data]); - // Seed notifications from loader data so the indicator renders instantly. - // Runs on every revalidation to keep loader → store in sync. - useEffect(() => { seedNotifications(data.notifications); - }, [data.notifications]); + + return () => { + $data.set(undefined); + $workspaces.set([]); + $user.set(undefined); + seedNotifications([]); + }; + }, [data]); // Revalidate when the polled project count changes (e.g. a transfer was accepted). useEffect(() => { if (revalidateVersion > 0) { diff --git a/apps/builder/app/routes/_ui.$.test.ts b/apps/builder/app/routes/_ui.$.test.ts new file mode 100644 index 000000000000..eb93219ea6dc --- /dev/null +++ b/apps/builder/app/routes/_ui.$.test.ts @@ -0,0 +1,31 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { loader } from "./_ui.$"; + +describe("_ui.$ loader", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("returns 404 for missing apple touch icons without cross-origin logging", async () => { + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + const request = new Request( + "https://p-project.apps.webstudio.is/apple-touch-icon-precomposed.png", + { + headers: { + accept: "*/*", + }, + } + ); + + await expect( + loader({ + request, + params: {}, + context: {}, + }) + ).rejects.toMatchObject({ + status: 404, + }); + expect(consoleInfo).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/builder/app/routes/_ui.$.tsx b/apps/builder/app/routes/_ui.$.tsx index ac92fd860b43..0c40c08fdbbc 100644 --- a/apps/builder/app/routes/_ui.$.tsx +++ b/apps/builder/app/routes/_ui.$.tsx @@ -6,10 +6,6 @@ import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; export { ErrorBoundary } from "~/shared/error/error-boundary"; export const loader = async ({ request }: LoaderFunctionArgs) => { - preventCrossOriginCookie(request); - - // No data to protect with CSRF token - const url = new URL(request.url); // Redirecting asset files (e.g., .js, .css) to the dashboard should be avoided. @@ -28,6 +24,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }); } + preventCrossOriginCookie(request); + + // No data to protect with CSRF token + const contentType = request.headers.get("Content-Type"); if (contentType?.includes("application/json")) { diff --git a/apps/builder/app/routes/_ui.(builder).tsx b/apps/builder/app/routes/_ui.(builder).tsx index b04ec59cfdeb..01f0b6497bcc 100644 --- a/apps/builder/app/routes/_ui.(builder).tsx +++ b/apps/builder/app/routes/_ui.(builder).tsx @@ -38,6 +38,10 @@ import { } from "~/services/destinations.server"; import { loader as authWsLoader } from "./auth.ws"; import { getUserById } from "~/shared/db/user.server"; +import { + createPrivateNoStoreHeaders, + privateNoStoreResponseHeaders, +} from "~/services/cache-control.server"; export { ErrorBoundary } from "~/shared/error/error-boundary"; export const links = () => { @@ -221,7 +225,7 @@ export const loader = async (loaderArgs: LoaderFunctionArgs) => { throw new AuthorizationError("Project must have project userId defined"); } - const headers = new Headers(); + const headers = createPrivateNoStoreHeaders(); if (context.authorization.type === "token") { // To protect against cookie overwrites, we set a null session cookie if a user is using an authToken. @@ -287,9 +291,13 @@ export const loader = async (loaderArgs: LoaderFunctionArgs) => { * */ export const headers = ({ loaderHeaders }: HeadersArgs) => { + const contentSecurityPolicy = loaderHeaders.get("Content-Security-Policy"); + return { - "Cache-Control": "no-store", - "Content-Security-Policy": loaderHeaders.get("Content-Security-Policy"), + ...privateNoStoreResponseHeaders, + ...(contentSecurityPolicy === null + ? {} + : { "Content-Security-Policy": contentSecurityPolicy }), }; }; diff --git a/apps/builder/app/routes/_ui.dashboard.tsx b/apps/builder/app/routes/_ui.dashboard.tsx index 76ab0be5edc0..9729de6ca88e 100644 --- a/apps/builder/app/routes/_ui.dashboard.tsx +++ b/apps/builder/app/routes/_ui.dashboard.tsx @@ -1,17 +1,13 @@ import { lazy, useEffect } from "react"; import { preconnect, prefetchDNS } from "react-dom"; -import { - Outlet, - redirect, - type ShouldRevalidateFunction, -} from "react-router-dom"; +import { Outlet, type ShouldRevalidateFunction } from "react-router-dom"; import { useLoaderData, useLocation, useNavigate, type MetaFunction, } from "@remix-run/react"; -import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { createCallerFactory, AuthorizationError, @@ -29,6 +25,8 @@ import env from "~/env/env.server"; import { ClientOnly } from "~/shared/client-only"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { allowedDestinations } from "~/services/destinations.server"; +import { redirect } from "~/services/no-store-redirect"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; export { ErrorBoundary } from "~/shared/error/error-boundary"; import { findAuthenticatedUser } from "~/services/auth.server"; import { createContext } from "~/shared/context.server"; @@ -44,17 +42,8 @@ export const meta = () => { return metas; }; -/** - * When deleting/adding a project, then navigating to a new project and pressing the back button, - * the dashboard page may display stale data because it’s being retrieved from the browser’s back/forward cache (bfcache). - * - * https://web.dev/articles/bfcache - * - */ export const headers = () => { - return { - "Cache-Control": "no-store", - }; + return privateNoStoreResponseHeaders; }; const dashboardProjectCaller = createCallerFactory(dashboardProjectRouter); @@ -198,19 +187,22 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const projectToClone = await getProjectToClone(request, context); - return { - user, - projects, - planFeatures, - purchases, - publisherHost: env.PUBLISHER_HOST, - origin, - projectToClone, - workspaces, - currentWorkspaceId, - role, - notifications, - }; + return json( + { + user, + projects, + planFeatures, + purchases, + publisherHost: env.PUBLISHER_HOST, + origin, + projectToClone, + workspaces, + currentWorkspaceId, + role, + notifications, + }, + { headers: privateNoStoreResponseHeaders } + ); }; export const shouldRevalidate: ShouldRevalidateFunction = ({ diff --git a/apps/builder/app/routes/_ui.login._index.tsx b/apps/builder/app/routes/_ui.login._index.tsx index 5dea3e0137b9..f90ed4e4b313 100644 --- a/apps/builder/app/routes/_ui.login._index.tsx +++ b/apps/builder/app/routes/_ui.login._index.tsx @@ -21,6 +21,7 @@ import { lazy } from "react"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { redirect } from "~/services/no-store-redirect"; import { allowedDestinations } from "~/services/destinations.server"; +import { createPrivateNoStoreHeaders } from "~/services/cache-control.server"; export { ErrorBoundary } from "~/shared/error/error-boundary"; export const links: LinksFunction = () => { @@ -80,7 +81,7 @@ export const loader = async ({ throw redirect(returnTo); } - const headers = new Headers(); + const headers = createPrivateNoStoreHeaders(); headers.append("Set-Cookie", await returnToCookie.serialize(returnTo)); diff --git a/apps/builder/app/routes/_ui.logout.tsx b/apps/builder/app/routes/_ui.logout.tsx index 74fd57e1450b..9010b0642d00 100644 --- a/apps/builder/app/routes/_ui.logout.tsx +++ b/apps/builder/app/routes/_ui.logout.tsx @@ -5,11 +5,12 @@ import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { createCallerFactory } from "@webstudio-is/trpc-interface/index.server"; import { logoutRouter } from "~/services/logout-router.server"; import { createContext } from "~/shared/context.server"; -import { redirect } from "react-router-dom"; +import { redirect } from "~/services/no-store-redirect"; import { ClientOnly } from "~/shared/client-only"; import { useLoaderData, type MetaFunction } from "@remix-run/react"; import { lazy } from "react"; import { allowedDestinations } from "~/services/destinations.server"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; export { ErrorBoundary } from "~/shared/error/error-boundary"; const logoutCaller = createCallerFactory(logoutRouter); @@ -58,10 +59,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { `${builderUrl({ projectId, origin: url.origin })}builder-logout` ); - return json({ - redirectTo, - logoutUrls, - }); + return json( + { + redirectTo, + logoutUrls, + }, + { headers: privateNoStoreResponseHeaders } + ); } catch (error) { if (error instanceof Response) { throw error; diff --git a/apps/builder/app/routes/_ui.tsx b/apps/builder/app/routes/_ui.tsx index 5f3b6c725119..3fc1a6e906df 100644 --- a/apps/builder/app/routes/_ui.tsx +++ b/apps/builder/app/routes/_ui.tsx @@ -23,6 +23,10 @@ import { csrfToken as clientCsrfToken, updateCsrfToken, } from "~/shared/csrf.client"; +import { + createPrivateNoStoreHeaders, + privateNoStoreResponseHeaders, +} from "~/services/cache-control.server"; export const links: LinksFunction = () => { // `links` returns an array of objects whose @@ -57,10 +61,10 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const [csrfToken, setCookieValue] = await getCsrfTokenAndCookie(request); if (request.headers.get("sec-fetch-mode") !== "navigate") { - return json({ csrfToken: "" }); + return json({ csrfToken: "" }, { headers: privateNoStoreResponseHeaders }); } - const headers = new Headers(); + const headers = createPrivateNoStoreHeaders(); if (setCookieValue !== undefined) { headers.set("Set-Cookie", setCookieValue); diff --git a/apps/builder/app/routes/builder-logout.ts b/apps/builder/app/routes/builder-logout.ts index 23d7b4e97d20..7bee51dbe196 100644 --- a/apps/builder/app/routes/builder-logout.ts +++ b/apps/builder/app/routes/builder-logout.ts @@ -4,6 +4,10 @@ import { builderAuthenticator } from "~/services/builder-auth.server"; import { getAuthorizationServerOrigin } from "~/shared/router-utils/origins"; import { isBuilder, loginPath } from "~/shared/router-utils"; import { isRedirectResponse } from "~/services/cookie.server"; +import { + appendVaryHeader, + createPrivateNoStoreHeaders, +} from "~/services/cache-control.server"; const debug = createDebug(import.meta.url); @@ -29,11 +33,11 @@ const withCors = (request: Request, response: Response) => { return response; } - const headers = new Headers(response.headers); + const headers = createPrivateNoStoreHeaders(response.headers); for (const [name, value] of Object.entries(corsHeaders)) { headers.set(name, value); } - headers.append("Vary", "Origin"); + appendVaryHeader(headers, "Origin"); return new Response(response.body, { status: response.status, @@ -62,12 +66,12 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); } + const headers = createPrivateNoStoreHeaders(corsHeaders); + appendVaryHeader(headers, "Origin"); + return new Response(null, { status: 204, - headers: { - ...corsHeaders, - Vary: "Origin", - }, + headers, }); } diff --git a/apps/builder/app/routes/dashboard-logout.ts b/apps/builder/app/routes/dashboard-logout.ts index 7d949d854cbe..05a2e8da0ba0 100644 --- a/apps/builder/app/routes/dashboard-logout.ts +++ b/apps/builder/app/routes/dashboard-logout.ts @@ -1,10 +1,10 @@ -import { json } from "@remix-run/server-runtime"; +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { authenticator } from "~/services/auth.server"; import { isDashboard, loginPath } from "~/shared/router-utils"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; -import { type ActionFunctionArgs } from "react-router-dom"; import { checkCsrf } from "~/services/csrf-session.server"; import { isRedirectResponse } from "@remix-run/server-runtime/dist/responses"; +import { createPrivateNoStoreHeaders } from "~/services/cache-control.server"; export const action = async ({ request }: ActionFunctionArgs) => { try { @@ -24,7 +24,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { }); } catch (error) { if (error instanceof Response && isRedirectResponse(error)) { - const headers = new Headers(); + const headers = createPrivateNoStoreHeaders(); if (error.headers.get("Set-Cookie")) { headers.set("Set-Cookie", error.headers.get("Set-Cookie")!); diff --git a/apps/builder/app/routes/oauth.ws.authorize.tsx b/apps/builder/app/routes/oauth.ws.authorize.tsx index 49cf1daf9e9c..2398dbc636b0 100644 --- a/apps/builder/app/routes/oauth.ws.authorize.tsx +++ b/apps/builder/app/routes/oauth.ws.authorize.tsx @@ -20,6 +20,7 @@ import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import * as session from "~/services/session.server"; import { redirect } from "~/services/no-store-redirect"; import { allowedDestinations } from "~/services/destinations.server"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; const debug = createDebug(import.meta.url); @@ -107,7 +108,7 @@ export const loader: LoaderFunction = async ({ request }) => { error_description: "No redirect_uri provided", error_uri: "https://tools.ietf.org/html/rfc6749#section-3.1.2", }, - { status: 400 } + { status: 400, headers: privateNoStoreResponseHeaders } ); } @@ -131,7 +132,7 @@ export const loader: LoaderFunction = async ({ request }) => { "The redirect_uri provided does not match the registered redirect URIs.", error_uri: "https://tools.ietf.org/html/rfc6749#section-3.1.2", }, - { status: 400 } + { status: 400, headers: privateNoStoreResponseHeaders } ); } @@ -197,7 +198,7 @@ export const loader: LoaderFunction = async ({ request }) => { "The redirect_uri provided does not match the registered redirect URIs.", error_uri: "https://tools.ietf.org/html/rfc6749#section-3.1.2", }, - { status: 400 } + { status: 400, headers: privateNoStoreResponseHeaders } ); } @@ -249,7 +250,7 @@ export const loader: LoaderFunction = async ({ request }) => { error instanceof Error ? error.message : "Unknown error", error_uri: "", }, - { status: 500 } + { status: 500, headers: privateNoStoreResponseHeaders } ); } }; diff --git a/apps/builder/app/routes/oauth.ws.token.ts b/apps/builder/app/routes/oauth.ws.token.ts index fcc9dde3a431..4983d23f729a 100644 --- a/apps/builder/app/routes/oauth.ws.token.ts +++ b/apps/builder/app/routes/oauth.ws.token.ts @@ -12,6 +12,7 @@ import { import { isUserAuthorizedForProject } from "~/services/builder-access.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { isDashboard } from "~/shared/router-utils"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; /** * OAuth 2.0 Token Request @@ -57,7 +58,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { error_description: "missing client credentials", error_uri: "https://tools.ietf.org/html/rfc6749#section-5.2", }, - { status: 401 } + { status: 401, headers: privateNoStoreResponseHeaders } ); } @@ -82,7 +83,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { error_description: "invalid client credentials", error_uri: "https://tools.ietf.org/html/rfc6749#section-5.2", }, - { status: 401 } + { status: 401, headers: privateNoStoreResponseHeaders } ); } @@ -99,7 +100,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { error_description: fromError(parsedBody.error).toString(), error_uri: "https://tools.ietf.org/html/rfc6749#section-5.2", }, - { status: 400 } + { status: 400, headers: privateNoStoreResponseHeaders } ); } @@ -116,7 +117,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { error_description: "invalid code", error_uri: "https://tools.ietf.org/html/rfc6749#section-5.2", }, - { status: 400 } + { status: 400, headers: privateNoStoreResponseHeaders } ); } @@ -133,7 +134,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { error_description: "invalid code_verifier", error_uri: "https://tools.ietf.org/html/rfc6749#section-5.2", }, - { status: 400 } + { status: 400, headers: privateNoStoreResponseHeaders } ); } @@ -149,7 +150,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { error_description: "user does not have access to the project", error_uri: "https://tools.ietf.org/html/rfc6749#section-5.2", }, - { status: 400 } + { status: 400, headers: privateNoStoreResponseHeaders } ); } @@ -170,6 +171,6 @@ export const action = async ({ request }: ActionFunctionArgs) => { token_type: "Bearer", expires_in: Date.now() + maxAge, }, - { status: 200 } + { status: 200, headers: privateNoStoreResponseHeaders } ); }; diff --git a/apps/builder/app/routes/rest.assets.test.ts b/apps/builder/app/routes/rest.assets.test.ts new file mode 100644 index 000000000000..40c179821153 --- /dev/null +++ b/apps/builder/app/routes/rest.assets.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "vitest"; +import { MaxAssetsPerProjectError } from "@webstudio-is/asset-uploader/index.server"; + +describe("MaxAssetsPerProjectError", () => { + test("can be classified by type", () => { + expect(new MaxAssetsPerProjectError(100)).toBeInstanceOf( + MaxAssetsPerProjectError + ); + }); +}); diff --git a/apps/builder/app/routes/rest.assets.tsx b/apps/builder/app/routes/rest.assets.tsx index 09daa0d862f4..481a67bc1072 100644 --- a/apps/builder/app/routes/rest.assets.tsx +++ b/apps/builder/app/routes/rest.assets.tsx @@ -1,26 +1,16 @@ -import type { - ActionFunctionArgs, - LoaderFunctionArgs, -} from "@remix-run/server-runtime"; -import type { Asset } from "@webstudio-is/sdk"; -import { - loadAssetsByProject, - createUploadName, -} from "@webstudio-is/asset-uploader/index.server"; +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; +import { createUploadName } from "@webstudio-is/asset-uploader/index.server"; import { createContext } from "~/shared/context.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { checkCsrf } from "~/services/csrf-session.server"; import { parseError } from "~/shared/error/error-parse"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; -export const loader = async ({ - params, - request, -}: LoaderFunctionArgs): Promise> => { - if (params.projectId === undefined) { - throw new Error("Project id undefined"); - } - const context = await createContext(request); - return await loadAssetsByProject(params.projectId, context); +export const loader = async () => { + return json( + { errors: "Method not allowed" }, + { status: 405, headers: privateNoStoreResponseHeaders } + ); }; export const action = async (props: ActionFunctionArgs) => { @@ -55,15 +45,19 @@ export const action = async (props: ActionFunctionArgs) => { }, context ); - return { - name, - }; + return json({ name }, { headers: privateNoStoreResponseHeaders }); } + + return json( + { errors: "Method not allowed" }, + { status: 405, headers: privateNoStoreResponseHeaders } + ); } catch (error) { console.error(error); - return { - errors: parseError(error).message, - }; + return json( + { errors: parseError(error).message }, + { headers: privateNoStoreResponseHeaders } + ); } }; diff --git a/apps/builder/app/routes/rest.assets_.$name.tsx b/apps/builder/app/routes/rest.assets_.$name.tsx index d59fa8f17134..b6f24be941db 100644 --- a/apps/builder/app/routes/rest.assets_.$name.tsx +++ b/apps/builder/app/routes/rest.assets_.$name.tsx @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { ActionFunctionArgs } from "@remix-run/server-runtime"; +import { json, type ActionFunctionArgs } from "@remix-run/server-runtime"; import { uploadFile } from "@webstudio-is/asset-uploader/index.server"; import { isAllowedMimeCategory, @@ -12,14 +12,13 @@ import { createContext } from "~/shared/context.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { checkCsrf } from "~/services/csrf-session.server"; import { parseError } from "~/shared/error/error-parse"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; const UrlBody = z.object({ url: z.string(), }); -export const action = async ( - props: ActionFunctionArgs -): Promise => { +export const action = async (props: ActionFunctionArgs) => { preventCrossOriginCookie(props.request); await checkCsrf(props.request); @@ -121,19 +120,21 @@ export const action = async ( context, assetInfoFallback ); - return { - uploadedAssets: [asset], - }; + return json({ uploadedAssets: [asset] } satisfies AssetActionResponse, { + headers: privateNoStoreResponseHeaders, + }); } } catch (error) { console.error(error); - return { - errors: parseError(error).message, - }; + return json( + { errors: parseError(error).message } satisfies AssetActionResponse, + { headers: privateNoStoreResponseHeaders } + ); } - return { - errors: "Method not allowed", - }; + return json({ errors: "Method not allowed" } satisfies AssetActionResponse, { + status: 405, + headers: privateNoStoreResponseHeaders, + }); }; diff --git a/apps/builder/app/routes/rest.data.$projectId.ts b/apps/builder/app/routes/rest.data.$projectId.ts index b342555f9265..4b4249c16bce 100644 --- a/apps/builder/app/routes/rest.data.$projectId.ts +++ b/apps/builder/app/routes/rest.data.$projectId.ts @@ -1,13 +1,10 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import * as projectApi from "@webstudio-is/project/index.server"; -import { loadDevBuildByProjectId } from "@webstudio-is/project-build/index.server"; -import { serializePages } from "@webstudio-is/project-migrations/pages"; -import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server"; +import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime"; import { createContext } from "~/shared/context.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { checkCsrf } from "~/services/csrf-session.server"; import { allowedDestinations } from "~/services/destinations.server"; -import env from "~/env/env.server"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; +import { loadBuilderDataByProjectId } from "~/services/build-router.server"; export const loader = async ({ params, request }: LoaderFunctionArgs) => { preventCrossOriginCookie(request); @@ -18,20 +15,7 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { throw new Error("Project id undefined"); } const context = await createContext(request); - const project = await projectApi.loadById(params.projectId, context); - if (project === null) { - throw new Error(`Project "${params.projectId}" not found`); - } - if (project.userId === null) { - throw new Error("Project must have project userId defined"); - } - const build = await loadDevBuildByProjectId(context, project.id); - const assets = await loadAssetsByProject(project.id, context); - return { - ...build, - pages: serializePages(build.pages), - assets, - project, - publisherHost: env.PUBLISHER_HOST, - }; + const data = await loadBuilderDataByProjectId(params.projectId, context); + + return json(data, { headers: privateNoStoreResponseHeaders }); }; diff --git a/apps/builder/app/routes/rest.resources-loader.ts b/apps/builder/app/routes/rest.resources-loader.ts index 5ae0baf13af9..c682001dfa03 100644 --- a/apps/builder/app/routes/rest.resources-loader.ts +++ b/apps/builder/app/routes/rest.resources-loader.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { type ActionFunctionArgs, data } from "@remix-run/server-runtime"; +import { type ActionFunctionArgs, data, json } from "@remix-run/server-runtime"; import { ResourceRequest } from "@webstudio-is/sdk"; import { isLocalResource, loadResource } from "@webstudio-is/sdk/runtime"; import { loader as siteMapLoader } from "../shared/$resources/sitemap.xml.server"; @@ -8,6 +8,7 @@ import { loader as assetsLoader } from "../shared/$resources/assets.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { checkCsrf } from "~/services/csrf-session.server"; import { getResourceKey } from "~/shared/resources"; +import { privateNoStoreResponseHeaders } from "~/services/cache-control.server"; export const action = async ({ request }: ActionFunctionArgs) => { preventCrossOriginCookie(request); @@ -41,6 +42,7 @@ export const action = async ({ request }: ActionFunctionArgs) => { console.error("data:", requestJson); throw data(requestList.error, { status: 400, + headers: privateNoStoreResponseHeaders, }); } @@ -65,5 +67,5 @@ export const action = async ({ request }: ActionFunctionArgs) => { }) ); - return output; + return json(output, { headers: privateNoStoreResponseHeaders }); }; diff --git a/apps/builder/app/routes/trpc.$.ts b/apps/builder/app/routes/trpc.$.ts index 78c931141339..45647884f8dc 100644 --- a/apps/builder/app/routes/trpc.$.ts +++ b/apps/builder/app/routes/trpc.$.ts @@ -4,6 +4,7 @@ import { createContext, isServiceAuthorization } from "~/shared/context.server"; import { appRouter } from "~/services/trcp-router.server"; import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie"; import { checkCsrf } from "~/services/csrf-session.server"; +import { getTrpcResponseMeta } from "~/services/trpc-response-meta.server"; const isServiceRequest = (request: Request) => { return isServiceAuthorization(request.headers.get("Authorization")); @@ -28,50 +29,10 @@ export const action = async ({ request }: ActionFunctionArgs) => { endpoint: "/trpc", batching: { enabled: true }, responseMeta(opts) { - // Disable trpc cache - if (process.env.NODE_ENV !== "production") { - return {}; - } - - // tRPC batches multiple requests into a single network call. - // The `paths` array lists all request paths included in the batch. - const { paths, errors, type, ctx } = opts; - - if (paths === undefined) { - return {}; - } - - if (type !== "query") { - // Only queries can be cached - return {}; - } - - if (errors.length > 0) { - // Errors should not be cached - return {}; - } - - // To enable efficient batching of tRPC requests, - // adopt the least max age among all paths for caching, or disable caching entirely if no max-age is set. - let minMaxAge = Number.MAX_SAFE_INTEGER; - for (const path of paths) { - const maxAge = ctx?.trpcCache.getMaxAge(path); - - if (maxAge === undefined) { - return {}; - } - - minMaxAge = Math.min(minMaxAge, maxAge); - } - - // Cap the max age at 1 hour - minMaxAge = Math.min(minMaxAge, 60 * 60); - - return { - headers: { - "Cache-Control": `public, max-age=${minMaxAge}, s-maxage=${minMaxAge}`, - }, - }; + return getTrpcResponseMeta({ + ...opts, + isProduction: process.env.NODE_ENV === "production", + }); }, async createContext(opts) { return await createContext(opts.req); diff --git a/apps/builder/app/services/build-router.server.ts b/apps/builder/app/services/build-router.server.ts index 41c9815be04a..c35a9c7befcb 100644 --- a/apps/builder/app/services/build-router.server.ts +++ b/apps/builder/app/services/build-router.server.ts @@ -17,7 +17,11 @@ import { toPatchResult, } from "~/shared/sync/patch/patch-service.server"; import { normalizePatchRequest } from "~/shared/sync/patch/patch-normalize.server"; +import * as projectApi from "@webstudio-is/project/index.server"; import type { BuildPatchTransaction } from "@webstudio-is/project/index.server"; +import { loadDevBuildByProjectId } from "@webstudio-is/project-build/index.server"; +import { serializePages } from "@webstudio-is/project-migrations/pages"; +import { loadAssetsByProject } from "@webstudio-is/asset-uploader/index.server"; import { loadPublishedProjectDataByBuildId, loadPublishedProjectDataByProjectId, @@ -96,7 +100,37 @@ const assertRelayPatchContext = ( } }; +export const loadBuilderDataByProjectId = async ( + projectId: string, + ctx: AppContext +) => { + const project = await projectApi.loadById(projectId, ctx); + if (project === null) { + throw new Error(`Project "${projectId}" not found`); + } + if (project.userId === null) { + throw new Error("Project must have project userId defined"); + } + + const build = await loadDevBuildByProjectId(ctx, project.id); + const assets = await loadAssetsByProject(project.id, ctx); + + return { + ...build, + pages: serializePages(build.pages), + assets, + project, + publisherHost: env.PUBLISHER_HOST, + }; +}; + export const buildRouter = router({ + loadData: procedure + .input(z.object({ projectId: z.string() })) + .query(async ({ ctx, input }) => { + return await loadBuilderDataByProjectId(input.projectId, ctx); + }), + loadProjectDataByBuildId: procedure .input(z.object({ buildId: z.string() })) .query(async ({ ctx, input }) => { diff --git a/apps/builder/app/services/cache-control.server.test.ts b/apps/builder/app/services/cache-control.server.test.ts new file mode 100644 index 000000000000..f96b359bcacd --- /dev/null +++ b/apps/builder/app/services/cache-control.server.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "vitest"; +import { + appendVaryHeader, + createPrivateNoStoreHeaders, + privateNoStoreResponseHeaders, +} from "./cache-control.server"; + +describe("private no-store cache headers", () => { + test("marks user-scoped responses as non-cacheable and cookie-varying", () => { + expect(privateNoStoreResponseHeaders).toMatchObject({ + "Cache-Control": "private, no-store, max-age=0", + Pragma: "no-cache", + Expires: "0", + Vary: "Cookie", + }); + }); + + test("preserves unrelated headers while enforcing cache policy", () => { + const headers = createPrivateNoStoreHeaders({ + "Cache-Control": "public, max-age=600", + "Set-Cookie": "session=value", + }); + + expect(headers.get("Cache-Control")).toBe( + privateNoStoreResponseHeaders["Cache-Control"] + ); + expect(headers.get("Vary")).toBe(privateNoStoreResponseHeaders.Vary); + expect(headers.get("Set-Cookie")).toBe("session=value"); + }); + + test("preserves existing vary values while adding cookie", () => { + const headers = createPrivateNoStoreHeaders({ + Vary: "Origin", + }); + + expect(headers.get("Vary")).toBe("Origin, Cookie"); + }); + + test("does not duplicate vary values", () => { + const headers = new Headers({ + Vary: "Origin, Cookie", + }); + + appendVaryHeader(headers, "Origin"); + appendVaryHeader(headers, "Cookie"); + + expect(headers.get("Vary")).toBe("Origin, Cookie"); + }); +}); diff --git a/apps/builder/app/services/cache-control.server.ts b/apps/builder/app/services/cache-control.server.ts new file mode 100644 index 000000000000..9a7bdba96698 --- /dev/null +++ b/apps/builder/app/services/cache-control.server.ts @@ -0,0 +1,34 @@ +export const privateNoStoreResponseHeaders = { + "Cache-Control": "private, no-store, max-age=0", + Pragma: "no-cache", + Expires: "0", + Vary: "Cookie", +}; + +export const appendVaryHeader = (headers: Headers, value: string) => { + const vary = headers.get("Vary"); + if (vary === null || vary.trim() === "") { + headers.set("Vary", value); + return; + } + + const varyValues = vary.split(",").map((item) => item.trim().toLowerCase()); + if (varyValues.includes(value.toLowerCase()) === false) { + headers.set("Vary", `${vary}, ${value}`); + } +}; + +export const createPrivateNoStoreHeaders = (init?: HeadersInit) => { + const headers = new Headers(init); + + for (const [name, value] of Object.entries(privateNoStoreResponseHeaders)) { + if (name.toLowerCase() === "vary") { + continue; + } + headers.set(name, value); + } + + appendVaryHeader(headers, privateNoStoreResponseHeaders.Vary); + + return headers; +}; diff --git a/apps/builder/app/services/no-cross-origin-cookie.test.ts b/apps/builder/app/services/no-cross-origin-cookie.test.ts new file mode 100644 index 000000000000..b6159f9ec2df --- /dev/null +++ b/apps/builder/app/services/no-cross-origin-cookie.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; +import { preventCrossOriginCookie } from "./no-cross-origin-cookie"; + +describe("preventCrossOriginCookie", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("logs blocked cross-origin requests as info", () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + const request = new Request("https://apps.webstudio.is/rest/data", { + method: "POST", + headers: { + cookie: "session=1", + }, + }); + + expect(() => preventCrossOriginCookie(request)).toThrow(); + + expect(consoleError).not.toHaveBeenCalled(); + expect(consoleInfo).toHaveBeenCalledWith( + "Blocked cross-origin request to https://apps.webstudio.is/rest/data", + [] + ); + expect(request.headers.has("cookie")).toBe(false); + }); +}); diff --git a/apps/builder/app/services/no-cross-origin-cookie.ts b/apps/builder/app/services/no-cross-origin-cookie.ts index 36fa78c84df8..9b3e6542ccf8 100644 --- a/apps/builder/app/services/no-cross-origin-cookie.ts +++ b/apps/builder/app/services/no-cross-origin-cookie.ts @@ -41,7 +41,7 @@ export const preventCrossOriginCookie = ( } if (throwError) { - console.error(`Cross-origin request to ${request.url} blocked`, [ + console.info(`Blocked cross-origin request to ${request.url}`, [ ...request.headers.entries(), ]); diff --git a/apps/builder/app/services/no-store-redirect.test.ts b/apps/builder/app/services/no-store-redirect.test.ts new file mode 100644 index 000000000000..a3d88af5034f --- /dev/null +++ b/apps/builder/app/services/no-store-redirect.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "vitest"; +import { privateNoStoreResponseHeaders } from "./cache-control.server"; +import { redirect, setNoStoreToRedirect } from "./no-store-redirect"; + +describe("no-store redirects", () => { + test("creates redirects with private no-store headers", () => { + const response = redirect("/login"); + + expect(response.status).toBe(302); + expect(response.headers.get("Location")).toBe("/login"); + expect(response.headers.get("Cache-Control")).toBe( + privateNoStoreResponseHeaders["Cache-Control"] + ); + expect(response.headers.get("Vary")).toBe( + privateNoStoreResponseHeaders.Vary + ); + }); + + test("adds private no-store headers to existing redirects without losing headers", () => { + const response = new Response(null, { + status: 302, + headers: { + Location: "/dashboard", + "Set-Cookie": "session=value", + Vary: "Origin", + }, + }); + + const result = setNoStoreToRedirect(response); + + expect(result.headers.get("Location")).toBe("/dashboard"); + expect(result.headers.get("Set-Cookie")).toBe("session=value"); + expect(result.headers.get("Cache-Control")).toBe( + privateNoStoreResponseHeaders["Cache-Control"] + ); + expect(result.headers.get("Vary")).toBe("Origin, Cookie"); + }); +}); diff --git a/apps/builder/app/services/no-store-redirect.ts b/apps/builder/app/services/no-store-redirect.ts index 4a838d0a2c2d..52918ad36720 100644 --- a/apps/builder/app/services/no-store-redirect.ts +++ b/apps/builder/app/services/no-store-redirect.ts @@ -1,5 +1,9 @@ import { redirect as remixRedirect } from "@remix-run/server-runtime"; import { isRedirectResponse } from "./cookie.server"; +import { + createPrivateNoStoreHeaders, + privateNoStoreResponseHeaders, +} from "./cache-control.server"; /** * Chrome aggressively uses cache when restoring tabs (e.g., using Shift+Command+T or automatic session restore). @@ -14,10 +18,12 @@ import { isRedirectResponse } from "./cookie.server"; export const redirect: typeof remixRedirect = (url, init) => { const headers = typeof init === "object" ? new Headers(init.headers) : new Headers(); - headers.set("Cache-Control", "no-store"); + const noStoreHeaders = createPrivateNoStoreHeaders(headers); const responseInit: ResponseInit = - typeof init === "number" ? { status: init, headers } : { ...init, headers }; + typeof init === "number" + ? { status: init, headers: noStoreHeaders } + : { ...init, headers: noStoreHeaders }; return remixRedirect(url, responseInit); }; @@ -35,7 +41,13 @@ export const redirect: typeof remixRedirect = (url, init) => { export const setNoStoreToRedirect = (response: Response) => { if (isRedirectResponse(response)) { const newResponse = new Response(response.body, response); - newResponse.headers.set("Cache-Control", "no-store"); + const noStoreHeaders = createPrivateNoStoreHeaders(newResponse.headers); + for (const name of Object.keys(privateNoStoreResponseHeaders)) { + const value = noStoreHeaders.get(name); + if (value !== null) { + newResponse.headers.set(name, value); + } + } return newResponse; } diff --git a/apps/builder/app/services/trpc-response-meta.server.test.ts b/apps/builder/app/services/trpc-response-meta.server.test.ts new file mode 100644 index 000000000000..a1df40cefd67 --- /dev/null +++ b/apps/builder/app/services/trpc-response-meta.server.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; +import { privateNoStoreResponseHeaders } from "./cache-control.server"; +import { getTrpcResponseMeta } from "./trpc-response-meta.server"; + +const createContext = ( + authorization: AppContext["authorization"], + maxAges: Record = {} +): Pick => ({ + authorization, + trpcCache: { + setMaxAge: () => {}, + getMaxAge: (path) => maxAges[path], + }, +}); + +describe("tRPC response cache metadata", () => { + test("marks development responses as private no-store", () => { + expect( + getTrpcResponseMeta({ + isProduction: false, + paths: ["marketplace.getItems"], + errors: [], + type: "query", + ctx: createContext( + { type: "anonymous" }, + { "marketplace.getItems": 60 } + ), + }) + ).toEqual({ headers: privateNoStoreResponseHeaders }); + }); + + test("marks authenticated responses as private no-store even when procedures opt into public cache", () => { + expect( + getTrpcResponseMeta({ + isProduction: true, + paths: ["marketplace.getItems"], + errors: [], + type: "query", + ctx: createContext( + { + type: "user", + userId: "user-1", + sessionCreatedAt: 0, + isLoggedInToBuilder: async () => true, + }, + { "marketplace.getItems": 60 } + ), + }) + ).toEqual({ headers: privateNoStoreResponseHeaders }); + }); + + test("marks uncached anonymous queries as private no-store", () => { + expect( + getTrpcResponseMeta({ + isProduction: true, + paths: ["build.loadData"], + errors: [], + type: "query", + ctx: createContext({ type: "anonymous" }), + }) + ).toEqual({ headers: privateNoStoreResponseHeaders }); + }); + + test("marks empty batches as private no-store", () => { + expect( + getTrpcResponseMeta({ + isProduction: true, + paths: [], + errors: [], + type: "query", + ctx: createContext({ type: "anonymous" }), + }) + ).toEqual({ headers: privateNoStoreResponseHeaders }); + }); + + test("uses public cache for anonymous queries only when every batched path opts in", () => { + expect( + getTrpcResponseMeta({ + isProduction: true, + paths: ["marketplace.getItems", "marketplace.getBuildData"], + errors: [], + type: "query", + ctx: createContext( + { type: "anonymous" }, + { + "marketplace.getItems": 180, + "marketplace.getBuildData": 90, + } + ), + }) + ).toEqual({ + headers: { + "Cache-Control": "public, max-age=90, s-maxage=90", + }, + }); + }); +}); diff --git a/apps/builder/app/services/trpc-response-meta.server.ts b/apps/builder/app/services/trpc-response-meta.server.ts new file mode 100644 index 000000000000..106cddbfb508 --- /dev/null +++ b/apps/builder/app/services/trpc-response-meta.server.ts @@ -0,0 +1,58 @@ +import type { AppContext } from "@webstudio-is/trpc-interface/index.server"; +import { privateNoStoreResponseHeaders } from "./cache-control.server"; + +type TrpcResponseMetaInput = { + paths?: readonly string[]; + errors: readonly unknown[]; + type: string; + ctx?: Pick; + isProduction: boolean; +}; + +const privateNoStoreResponse = { + headers: privateNoStoreResponseHeaders, +}; + +export const getTrpcResponseMeta = ({ + paths, + errors, + type, + ctx, + isProduction, +}: TrpcResponseMetaInput) => { + if (isProduction === false) { + return privateNoStoreResponse; + } + + if ( + paths === undefined || + paths.length === 0 || + type !== "query" || + errors.length > 0 + ) { + return privateNoStoreResponse; + } + + if (ctx?.authorization.type !== "anonymous") { + return privateNoStoreResponse; + } + + let minMaxAge = Number.MAX_SAFE_INTEGER; + for (const path of paths) { + const maxAge = ctx.trpcCache.getMaxAge(path); + + if (maxAge === undefined) { + return privateNoStoreResponse; + } + + minMaxAge = Math.min(minMaxAge, maxAge); + } + + minMaxAge = Math.min(minMaxAge, 60 * 60); + + return { + headers: { + "Cache-Control": `public, max-age=${minMaxAge}, s-maxage=${minMaxAge}`, + }, + }; +}; diff --git a/apps/builder/app/shared/builder-data.ts b/apps/builder/app/shared/builder-data.ts index 5039203b6ab3..a7475e202b00 100644 --- a/apps/builder/app/shared/builder-data.ts +++ b/apps/builder/app/shared/builder-data.ts @@ -2,7 +2,8 @@ import { getStyleDeclKey, type WebstudioData } from "@webstudio-is/sdk"; import { migratePages } from "@webstudio-is/project-migrations/pages"; import type { MarketplaceProduct } from "@webstudio-is/project-build"; import type { Project } from "@webstudio-is/project"; -import type { loader } from "~/routes/rest.data.$projectId"; +import type { inferRouterOutputs } from "@trpc/server"; +import type { AppRouter } from "~/services/trcp-router.server"; import { $project } from "~/shared/sync/data-stores"; import { $assets, @@ -17,18 +18,17 @@ import { $styleSources, $styles, } from "~/shared/sync/data-stores"; -import { fetch } from "~/shared/fetch.client"; +import { nativeClient } from "~/shared/trpc/trpc-client"; export type BuilderData = WebstudioData & { marketplaceProduct: undefined | MarketplaceProduct; project: Project; }; +type LoadDataOutput = inferRouterOutputs["build"]["loadData"]; + export type LoadedBuilderData = BuilderData & - Pick< - Awaited>, - "id" | "version" | "publisherHost" | "projectId" - >; + Pick; export const getBuilderData = (): BuilderData => { const pages = $pages.get(); @@ -65,14 +65,11 @@ export const loadBuilderData = async ({ projectId: string; signal: AbortSignal; }): Promise => { - const currentUrl = new URL(location.href); - const url = new URL(`/rest/data/${projectId}`, currentUrl.origin); - const headers = new Headers(); - - const response = await fetch(url, { headers, signal }); - - if (response.ok) { - const data = (await response.json()) as Awaited>; + try { + const data = await nativeClient.build.loadData.query( + { projectId }, + { signal } + ); return { id: data.id, version: data.version, @@ -93,16 +90,17 @@ export const loadBuilderData = async ({ styles: new Map(data.styles.map((item) => [getStyleDeclKey(item), item])), marketplaceProduct: data.marketplaceProduct, }; - } - - const text = await response.text(); + } catch (error) { + const message = + error instanceof Error ? error.message : JSON.stringify(error); - // No toasts available in this context - alert( - `Unable to load builder data. Response status: ${response.status}. Response text: ${text}` - ); + if (signal.aborted === false) { + // No toasts available in this context + alert(`Unable to load builder data. ${message}`); + } - throw Error( - `Unable to load builder data. Response status: ${response.status}. Response text: ${text}` - ); + throw new Error(`Unable to load builder data. ${message}`, { + cause: error, + }); + } }; diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx index 67a0ed251e08..6e7d860d9675 100644 --- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx @@ -3288,6 +3288,7 @@ describe("Styles", () => { background-image: linear-gradient(350deg,hsl(256.3636363636363 72.13% 23.92%/0.00),hsl(256.2162162162162 72.55% 80.00%/1.00) 49%,#bba7f1); -webkit-background-clip: text; background-clip: text; + -webkit-text-fill-color: transparent; color: transparent } }" diff --git a/apps/builder/app/shared/fetch.client.ts b/apps/builder/app/shared/fetch.client.ts index 6da8853d2f08..68b1445a5971 100644 --- a/apps/builder/app/shared/fetch.client.ts +++ b/apps/builder/app/shared/fetch.client.ts @@ -42,6 +42,7 @@ export const fetch: typeof globalThis.fetch = async ( const modifiedInit: RequestInit = { ...requestInit, + cache: requestInit?.cache ?? "no-store", headers, }; diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 4471ffcd8cb0..403b3f50a494 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -150,6 +150,32 @@ test("generate props from number and boolean aria attributes", () => { ); }); +test("skip webstudio runtime attributes from pasted html", () => { + const fragment = generateFragmentFromHtml(` + View solutions + `); + + expect(fragment.props).toEqual([ + expect.objectContaining({ name: "href", value: "#services" }), + ]); + expect(fragment.props.some((prop) => prop.name.startsWith("data-ws-"))).toBe( + false + ); + expect(fragment.instances[0]).toEqual( + expect.objectContaining({ + tag: "a", + children: [{ type: "text", value: "View solutions " }], + }) + ); +}); + test("wrap text with span when spotted outside of rich text", () => { expect( generateFragmentFromHtml(` @@ -254,6 +280,27 @@ test("generate style attribute as local styles", () => { ); }); +test("generate nested css math functions as unparsed local styles", () => { + const value = + "clamp(1rem, calc(1rem + (2rem - 1rem) * ((100vw - 20rem) / (80rem - 20rem))), 2rem)"; + const fragment = generateFragmentFromHtml(` +
One clamp div
+ `); + + expect(fragment.styles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: "fontSize", + value: { + type: "unparsed", + value: + "clamp(1rem,calc(1rem + (2rem - 1rem)*((100vw - 20rem)/(80rem - 20rem))),2rem)", + }, + }), + ]) + ); +}); + test("script as html embed", () => { expect(generateFragmentFromHtml(``)).toEqual( renderTemplate( diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index 0577c249dae8..8d8ee11aaca1 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -26,7 +26,10 @@ import { } from "@webstudio-is/css-data"; import { richTextContentTags } from "./content-model"; import { setIsSubsetOf } from "./shim"; -import { isAttributeNameSafe } from "@webstudio-is/react-sdk"; +import { + isAttributeNameSafe, + textContentAttribute, +} from "@webstudio-is/react-sdk"; import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk"; import * as csstree from "css-tree"; import { titleCase } from "title-case"; @@ -34,6 +37,7 @@ import { titleCase } from "title-case"; type ElementNode = DefaultTreeAdapterMap["element"]; const spaceRegex = /^\s*$/; +const wsAttributePrefix = "data-ws-"; const getAttributeType = ( attribute: (typeof ariaAttributes)[number] @@ -742,7 +746,15 @@ export const generateFragmentFromHtml = ( delete instance.tag; } instances.set(instance.id, instance); + const wsTextContentAttr = node.attrs.find( + (attr) => attr.name === textContentAttribute + ); for (const attr of node.attrs) { + // Webstudio runtime metadata can appear when users copy rendered canvas + // DOM. Do not import it as user-authored attributes. + if (attr.name.startsWith(wsAttributePrefix)) { + continue; + } // skip attributes which cannot be rendered in jsx if (!isAttributeNameSafe(attr.name)) { continue; @@ -887,6 +899,21 @@ export const generateFragmentFromHtml = ( } } } + if ( + wsTextContentAttr !== undefined && + node.tagName !== "textarea" && + node.childNodes.every((childNode) => + defaultTreeAdapter.isTextNode(childNode) + ) + ) { + if (wsTextContentAttr.value !== "") { + instance.children.push({ + type: "text", + value: wsTextContentAttr.value, + }); + } + return { type: "id" as const, value: instance.id }; + } let spaceAttachedToPrev = false; for (let index = 0; index < node.childNodes.length; index += 1) { const childNode = node.childNodes[index]; diff --git a/apps/builder/app/shared/system.test.ts b/apps/builder/app/shared/system.test.ts index 3ceeb4215fb2..916492449d6d 100644 --- a/apps/builder/app/shared/system.test.ts +++ b/apps/builder/app/shared/system.test.ts @@ -1,12 +1,20 @@ -import { describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test } from "vitest"; import type { Page, Pages } from "@webstudio-is/sdk"; import { $pages } from "~/shared/sync/data-stores"; import { registerContainers } from "~/shared/sync/sync-stores"; -import { updateCurrentSystem } from "./system"; +import { + $currentSystem, + $systemDataByPage, + updateCurrentSystem, +} from "./system"; import { selectPage } from "./nano-states"; registerContainers(); +beforeEach(() => { + $systemDataByPage.set(new Map()); +}); + const getInitialPages = (page: Page): Pages => ({ homePageId: "homeId", rootFolderId: "rootId", @@ -90,3 +98,86 @@ describe("history", () => { ]); }); }); + +test("system pathname includes parent folder slug", () => { + $pages.set({ + ...getInitialPages({ + id: "dynamicId", + path: "/post/:slug", + name: "", + title: "", + meta: {}, + rootInstanceId: "", + }), + folders: new Map([ + [ + "rootId", + { + id: "rootId", + name: "", + slug: "", + children: ["homeId", "folderId"], + }, + ], + [ + "folderId", + { + id: "folderId", + name: "Blog", + slug: "blog", + children: ["dynamicId"], + }, + ], + ]), + }); + selectPage("dynamicId"); + + updateCurrentSystem({ + params: { slug: "my-post" }, + }); + + expect($currentSystem.get().pathname).toBe("/blog/post/my-post"); + expect($pages.get()?.pages.get("dynamicId")?.history).toEqual([ + "/blog/post/my-post", + ]); +}); + +test("system params support legacy history without parent folder slug", () => { + $pages.set({ + ...getInitialPages({ + id: "dynamicId", + path: "/post/:slug", + name: "", + title: "", + meta: {}, + rootInstanceId: "", + history: ["/post/my-post"], + }), + folders: new Map([ + [ + "rootId", + { + id: "rootId", + name: "", + slug: "", + children: ["homeId", "folderId"], + }, + ], + [ + "folderId", + { + id: "folderId", + name: "Blog", + slug: "blog", + children: ["dynamicId"], + }, + ], + ]), + }); + selectPage("dynamicId"); + + expect($currentSystem.get()).toMatchObject({ + params: { slug: "my-post" }, + pathname: "/blog/post/my-post", + }); +}); diff --git a/apps/builder/app/shared/system.ts b/apps/builder/app/shared/system.ts index 7af3fd519657..fe69a9d13a34 100644 --- a/apps/builder/app/shared/system.ts +++ b/apps/builder/app/shared/system.ts @@ -1,5 +1,10 @@ import { atom, computed } from "nanostores"; -import { findPageByIdOrPath, type Page, type System } from "@webstudio-is/sdk"; +import { + findPageByIdOrPath, + getPagePath, + type Page, + type System, +} from "@webstudio-is/sdk"; import { compilePathnamePattern, matchPathnamePattern, @@ -14,13 +19,22 @@ export const $systemDataByPage = atom( new Map>() ); -const extractParams = (pattern: string, path?: string) => { +const extractParams = ( + pattern: string, + path?: string, + fallbackPattern?: string +) => { const params: System["params"] = {}; const tokens = tokenizePathnamePattern(pattern); // try to match the first item in history to let user // see the page without manually entering params // or selecting them in address bar - const matchedParams = path ? matchPathnamePattern(pattern, path) : undefined; + const matchedParams = path + ? (matchPathnamePattern(pattern, path) ?? + (fallbackPattern + ? matchPathnamePattern(fallbackPattern, path) + : undefined)) + : undefined; for (const token of tokens) { if (token.type === "param") { params[token.name] = matchedParams?.[token.name] ?? undefined; @@ -30,21 +44,26 @@ const extractParams = (pattern: string, path?: string) => { }; export const $currentSystem = computed( - [$publishedOrigin, $selectedPage, $systemDataByPage], - (origin, page, systemByPage) => { + [$publishedOrigin, $selectedPage, $pages, $systemDataByPage], + (origin, page, pages, systemByPage) => { const system: System = { search: {}, params: {}, pathname: "/", origin, }; - if (page === undefined) { + if (page === undefined || pages === undefined) { return system; } const systemData = systemByPage.get(page.id); - const extractedParams = extractParams(page.path, page.history?.[0]); + const pagePath = getPagePath(page.id, pages); + const extractedParams = extractParams( + pagePath, + page.history?.[0], + page.path + ); const params = { ...extractedParams, ...systemData?.params }; - const pathname = compilePath(page.path, params) || "/"; + const pathname = compilePath(pagePath, params) || "/"; return { search: { ...system.search, ...systemData?.search }, params, @@ -72,8 +91,9 @@ const savePathInHistory = (pageId: string, path: string) => { if (page === undefined) { return; } + const pagePath = getPagePath(page.id, pages); const history = Array.from(page.history ?? []); - history.unshift(path); + history.unshift(path || pagePath); page.history = Array.from(new Set(history)).slice(0, 20); }); }; @@ -91,5 +111,7 @@ export const updateCurrentSystem = ( const params = update.params ?? systemData?.params ?? {}; systemDataByPage.set(page.id, { search, params }); $systemDataByPage.set(systemDataByPage); - savePathInHistory(page.id, compilePath(page.path, params)); + const pages = $pages.get(); + const pagePath = pages ? getPagePath(page.id, pages) : page.path; + savePathInHistory(page.id, compilePath(pagePath, params)); }; diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx index d58fb71df53d..502511c939a9 100644 --- a/apps/builder/app/shared/tailwind/tailwind.test.tsx +++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; import { css, renderTemplate, ws } from "@webstudio-is/template"; -import { generateFragmentFromTailwind } from "./tailwind"; +import { __testing__, generateFragmentFromTailwind } from "./tailwind"; const getBaseStyleValue = ( fragment: Awaited>, @@ -41,6 +41,24 @@ const getStyleValue = ( )?.value; }; +test("normalize unocss output for webstudio parser", () => { + const { css: normalizedCss } = __testing__.normalizeUnoCssForWebstudio(` + @property --un-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; } + @property --un-from-opacity { syntax: ""; inherits: false; initial-value: 100%; } + .rounded-full { border-radius: calc(infinity * 1px); } + .from-brand { --un-gradient-from: color-mix(in oklab, #F5A623 var(--un-from-opacity), transparent); } + .bg-gradient { background-image: linear-gradient(to bottom right in oklab, var(--un-gradient-from) 0%, #E8920A 100%); } + .shadow { box-shadow: var(--un-shadow); } + `); + + expect(normalizedCss).toContain("--tw-shadow"); + expect(normalizedCss).toContain("border-radius: 9999px"); + expect(normalizedCss).toMatch( + /linear-gradient\(to bottom right,\s*#F5A623 0%, #E8920A 100%\)/ + ); + expect(normalizedCss).toContain("box-shadow: 0 0 #0000"); +}); + test("extract local styles from tailwind classes", async () => { expect( await generateFragmentFromTailwind( @@ -81,7 +99,6 @@ test("ignore dark mode", async () => { expect(getStyleValue(fragment, "backgroundColor")).toEqual( expect.objectContaining({ type: "color", - colorSpace: "srgb", components: [1, 1, 1], }) ); @@ -264,13 +281,288 @@ test("generate shadow", async () => { type: "layers", value: expect.arrayContaining([ expect.objectContaining({ - type: "var", - value: "tw-ring-offset-shadow", + type: "shadow", + offsetY: expect.objectContaining({ unit: "px", value: 1 }), + blur: expect.objectContaining({ unit: "px", value: 3 }), + }), + expect.objectContaining({ + type: "shadow", + offsetY: expect.objectContaining({ unit: "px", value: 1 }), + blur: expect.objectContaining({ unit: "px", value: 2 }), + spread: expect.objectContaining({ unit: "px", value: -1 }), + }), + ]), + }) + ); +}); + +test("generate arbitrary gradient, full radius and shadow", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + Free consultation + + ) + ); + + expect(fragment.props.some((prop) => prop.name === "class")).toBe(false); + expect(getStyleValue(fragment, "backgroundImage")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + value: expect.stringContaining("#F5A623"), + }), + ]), + }) + ); + expect(getStyleValue(fragment, "borderTopLeftRadius")).toEqual( + expect.objectContaining({ type: "unit", unit: "px", value: 9999 }) + ); + expect(getStyleValue(fragment, "boxShadow")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + type: "shadow", + offsetY: expect.objectContaining({ unit: "px", value: 4 }), + blur: expect.objectContaining({ unit: "px", value: 20 }), + color: expect.objectContaining({ alpha: 0.35 }), + }), + ]), + }) + ); +}); + +test("generate clipped gradient text", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + Your solar plant + + ) + ); + + expect(fragment.props.some((prop) => prop.name === "class")).toBe(false); + expect(getStyleValue(fragment, "color")).toEqual( + expect.objectContaining({ type: "keyword", value: "transparent" }) + ); + expect(getStyleValue(fragment, "backgroundClip")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ type: "keyword", value: "text" }), + ]), + }) + ); + expect(getStyleValue(fragment, "backgroundImage")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + value: expect.stringContaining("#F97316"), + }), + ]), + }) + ); +}); + +test("generate gradient background with via color stop", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + ) + ); + + expect(fragment.props.some((prop) => prop.name === "class")).toBe(false); + expect(getStyleValue(fragment, "backgroundImage")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + value: + "linear-gradient(to bottom right,#0A2830 0%,#0D4F5C 50%,#0A3040 100%)", }), - expect.objectContaining({ type: "var", value: "tw-ring-shadow" }), ]), }) ); + expect(getStyleValue(fragment, "paddingBlockStart")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 6 }) + ); + expect(getStyleValue(fragment, "paddingInlineStart")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 1 }) + ); +}); + +test("input padding utilities override preflight reset", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + + + + ) + ); + + const inputStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "1") + ?.values.at(-1); + const inputStyles = fragment.styles.filter( + (style) => style.styleSourceId === inputStyleSourceId + ); + + expect(inputStyles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + property: "width", + value: expect.objectContaining({ unit: "%", value: 100 }), + }), + expect.objectContaining({ + property: "paddingInlineStart", + value: expect.objectContaining({ unit: "rem", value: 1 }), + }), + expect.objectContaining({ + property: "paddingBlockStart", + value: expect.objectContaining({ unit: "rem", value: 0.75 }), + }), + ]) + ); + expect( + inputStyles.some((style) => + ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].includes( + style.property + ) + ) + ).toBe(false); + expect(getStyleValue(fragment, "gridTemplateColumns")).toEqual( + expect.objectContaining({ + type: "unparsed", + value: "repeat(2,minmax(0,1fr))", + }) + ); +}); + +test("axis padding utilities do not clear unrelated inline padding", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + ) + ); + + expect(getStyleValue(fragment, "paddingTop")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 2 }) + ); + expect(getStyleValue(fragment, "paddingInlineStart")).toEqual( + expect.objectContaining({ type: "unit", unit: "rem", value: 1 }) + ); + expect(getStyleValue(fragment, "paddingRight")).toBeUndefined(); +}); + +test("space-y form children stretch by default", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + + + + + + + + + Send request + + + + + ) + ); + + const cardStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "1") + ?.values.at(-1); + const formStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "2") + ?.values.at(-1); + + expect(fragment.styles).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + styleSourceId: cardStyleSourceId, + property: "maxWidth", + value: expect.objectContaining({ unit: "rem", value: 48 }), + }), + expect.objectContaining({ + styleSourceId: cardStyleSourceId, + property: "marginInlineStart", + value: expect.objectContaining({ type: "keyword", value: "auto" }), + }), + expect.objectContaining({ + styleSourceId: formStyleSourceId, + property: "display", + value: expect.objectContaining({ type: "keyword", value: "flex" }), + }), + expect.objectContaining({ + styleSourceId: formStyleSourceId, + property: "flexDirection", + value: expect.objectContaining({ type: "keyword", value: "column" }), + }), + ]) + ); + expect( + fragment.styles.some( + (style) => + style.styleSourceId === formStyleSourceId && + style.property === "alignItems" + ) + ).toBe(false); }); test("preserve or override existing local styles", async () => { @@ -438,16 +730,16 @@ describe("extract breakpoints", () => { max-width: 479px */ - @media (max-width: 479px) { + /* base -> max-width: 639px */ + @media (max-width: 639px) { opacity: 0.1; } /* min-width: 640px -> max-width: 767px */ @media (max-width: 767px) { opacity: 0.2; } - /* min-width: 768px -> max-width: 991px */ - @media (max-width: 991px) { + /* min-width: 768px -> max-width: 1023px */ + @media (max-width: 1023px) { opacity: 0.3; } /* min-width: 1024px -> base */ @@ -456,8 +748,8 @@ describe("extract breakpoints", () => { @media (min-width: 1280px) { opacity: 0.5; } - /* min-width: 1536px -> min-width: 1440px */ - @media (min-width: 1440px) { + /* min-width: 1536px -> min-width: 1536px */ + @media (min-width: 1536px) { opacity: 0.6; } `} @@ -481,7 +773,7 @@ describe("extract breakpoints", () => { { { { @media (min-width: 1280px) { color: red; } - @media (max-width: 479px) { + @media (max-width: 639px) { max-width: none; } @media (max-width: 767px) { max-width: 640px; } - @media (max-width: 991px) { + @media (max-width: 1023px) { max-width: 768px; } max-width: 1024px; @media (min-width: 1280px) { max-width: 1280px; } - @media (min-width: 1440px) { + @media (min-width: 1536px) { max-width: 1536px; } width: 100%; @@ -662,13 +954,13 @@ describe("extract breakpoints", () => { { color: red; } color: green; - @media (max-width: 479px) { + @media (max-width: 639px) { opacity: 0.1; } @media (max-width: 767px) { @@ -741,7 +1033,7 @@ describe("extract breakpoints", () => { ws:tag="div" ws:style={css` opacity: 0.1; - @media (max-width: 479px) { + @media (max-width: 639px) { &:hover { opacity: unset; } @@ -770,20 +1062,20 @@ describe("extract breakpoints", () => { { { { ); // container should only create max-width breakpoints, not 1280/1440/1920 min-width ones expect(fragment.breakpoints).toEqual([ - { id: "0", label: "479", maxWidth: 479 }, + { id: "0", label: "639", maxWidth: 639 }, { id: "1", label: "767", maxWidth: 767 }, - { id: "2", label: "991", maxWidth: 991 }, + { id: "2", label: "1023", maxWidth: 1023 }, { id: "base", label: "" }, ]); }); @@ -892,9 +1184,9 @@ describe("extract breakpoints", () => { ) ); - // sm: should only create 479 max-width and base, not all breakpoints + // sm: should only create 639 max-width and base, not all breakpoints expect(fragment.breakpoints).toEqual([ - { id: "0", label: "479", maxWidth: 479 }, + { id: "0", label: "639", maxWidth: 639 }, { id: "base", label: "" }, ]); }); @@ -928,7 +1220,6 @@ test("generate space without display property", async () => { ws:style={css` display: flex; flex-direction: column; - align-items: start; @media (max-width: 767px) { row-gap: 1rem; } diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts index d65d03326bb9..857a9c69d123 100644 --- a/apps/builder/app/shared/tailwind/tailwind.ts +++ b/apps/builder/app/shared/tailwind/tailwind.ts @@ -20,26 +20,14 @@ import { preflight } from "./__generated__/preflight"; // breakpoints used to map tailwind classes to webstudio breakpoints // includes both min-width (desktop-first) and max-width (mobile-first) breakpoints const tailwindBreakpoints: Breakpoint[] = [ - { id: "1920", label: "1920", minWidth: 1920 }, - { id: "1440", label: "1440", minWidth: 1440 }, + { id: "1536", label: "1536", minWidth: 1536 }, { id: "1280", label: "1280", minWidth: 1280 }, { id: "base", label: "" }, - { id: "991", label: "991", maxWidth: 991 }, + { id: "1023", label: "1023", maxWidth: 1023 }, { id: "767", label: "767", maxWidth: 767 }, - { id: "479", label: "479", maxWidth: 479 }, + { id: "639", label: "639", maxWidth: 639 }, ]; -const tailwindToWebstudioMappings: Record = { - 639.9: 479, - 640: 480, - 767.9: 767, - 1023.9: 991, - 1024: 992, - 1279.9: 1279, - 1535.9: 1439, - 1536: 1440, -}; - type StyleDecl = Omit; type StyleBreakpoint = { @@ -143,6 +131,10 @@ const rangesToBreakpoints = ( return breakpoints; }; +const normalizeMediaQueryWidth = (value: number, direction: "min" | "max") => { + return direction === "min" ? Math.ceil(value) : Math.floor(value); +}; + const adaptBreakpoints = ( parsedStyles: StyleDecl[], userBreakpoints: Breakpoint[] @@ -164,13 +156,17 @@ const adaptBreakpoints = ( ) { continue; } - if (mediaQuery?.minWidth) { - mediaQuery.minWidth = - tailwindToWebstudioMappings[mediaQuery.minWidth] ?? mediaQuery.minWidth; + if (mediaQuery?.minWidth !== undefined) { + mediaQuery.minWidth = normalizeMediaQueryWidth( + mediaQuery.minWidth, + "min" + ); } - if (mediaQuery?.maxWidth) { - mediaQuery.maxWidth = - tailwindToWebstudioMappings[mediaQuery.maxWidth] ?? mediaQuery.maxWidth; + if (mediaQuery?.maxWidth !== undefined) { + mediaQuery.maxWidth = normalizeMediaQueryWidth( + mediaQuery.maxWidth, + "max" + ); } const groupKey = `${styleDecl.property}:${styleDecl.state ?? ""}`; let group = breakpointGroups.get(groupKey); @@ -235,7 +231,85 @@ const hexToRgb = (hex: string) => { return `${r} ${g} ${b}`; }; -const normalizeWind4Css = (css: string, finalVars: Map) => { +const extractPropertyInitialValues = (css: string) => { + const values = new Map(); + for (const match of css.matchAll(/@property\s+(--[\w-]+)\s*\{([^{}]*)\}/g)) { + const initialValue = match[2].match(/initial-value\s*:\s*([^;]+)\s*;?/); + if (initialValue) { + values.set(match[1], initialValue[1].trim()); + } + } + return values; +}; + +const findMatchingParen = (text: string, openIndex: number) => { + let depth = 0; + for (let index = openIndex; index < text.length; index += 1) { + const char = text[index]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + if (depth === 0) { + return index; + } + } + } +}; + +const splitCssVarArguments = (args: string) => { + let depth = 0; + for (let index = 0; index < args.length; index += 1) { + const char = args[index]; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth -= 1; + } else if (char === "," && depth === 0) { + return [args.slice(0, index).trim(), args.slice(index + 1).trim()]; + } + } + return [args.trim()]; +}; + +const resolveCssVars = ( + value: string, + vars: Map, + seen = new Set() +): string => { + let result = ""; + let index = 0; + while (index < value.length) { + const varIndex = value.indexOf("var(", index); + if (varIndex === -1) { + result += value.slice(index); + break; + } + result += value.slice(index, varIndex); + const openIndex = varIndex + "var".length; + const closeIndex = findMatchingParen(value, openIndex); + if (closeIndex === undefined) { + result += value.slice(varIndex); + break; + } + const args = value.slice(openIndex + 1, closeIndex); + const [name, fallback] = splitCssVarArguments(args); + const replacement = vars.get(name); + if (replacement !== undefined && seen.has(name) === false) { + seen.add(name); + result += resolveCssVars(replacement, vars, seen); + seen.delete(name); + } else if (fallback !== undefined) { + result += resolveCssVars(fallback, vars, seen); + } else { + result += value.slice(varIndex, closeIndex + 1); + } + index = closeIndex + 1; + } + return result; +}; + +const normalizeUnoCssValues = (css: string, finalVars: Map) => { // Wind4 emits rem media queries (e.g. 40rem). Convert to px so existing // breakpoint mapping code can keep working unchanged. let normalized = css.replace( @@ -254,13 +328,9 @@ const normalizeWind4Css = (css: string, finalVars: Map) => { } ); - // Inline tracked theme variables so parseCss can resolve computed values - // like calc(var(--spacing) * 2) and var(--text-sm-fontSize). - for (const [name, value] of finalVars.entries()) { - normalized = normalized - .replaceAll(`var(${name})`, value) - .replaceAll(`var(${name},`, `var(${value},`); - } + // Inline tracked theme and utility variables so parseCss can resolve computed + // values like calc(var(--spacing) * 2), gradients, and shadow fallbacks. + normalized = resolveCssVars(normalized, finalVars); // Wind4 uses a leading utility var fallback for typography. normalized = normalized.replace( @@ -268,6 +338,8 @@ const normalizeWind4Css = (css: string, finalVars: Map) => { (_match, fallback) => fallback.trim() ); + normalized = normalized.replaceAll("calc(infinity * 1px)", "9999px"); + // Resolve wind4's color-mix based opacity pipeline into concrete colors that // parseCss can read as typed color values. normalized = normalized.replace( @@ -326,9 +398,52 @@ const normalizeWind4Css = (css: string, finalVars: Map) => { } ); + // Tailwind v4 emits gradients like `linear-gradient(to bottom right in oklab, ...)`. + // Keep imported gradients broadly renderable and parseable by dropping the + // interpolation color space from the direction argument. + normalized = normalized.replace( + /linear-gradient\((to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?|[-+]?\d*\.?\d+(?:deg|rad|grad|turn))\s+in\s+(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|lab|oklab|lch|oklch|xyz(?:-d50|-d65)?),/gi, + "linear-gradient($1," + ); + return normalized; }; +const normalizeUnoCssForWebstudio = (generatedCss: string) => { + // UnoCSS uses the --un-* namespace. Keep generated CSS in Tailwind's + // namespace so custom properties match familiar Tailwind output and existing + // Webstudio styles. + const css = generatedCss.replaceAll("--un-", "--tw-"); + + // Normalize CSS custom property values: when the same var is declared in + // multiple utility-class rules, replace every occurrence with the value from + // the LAST declaration (the final cascaded value). This allows per-rule + // two-pass pre-collection to see the correct final value regardless of which + // rule a shorthand (e.g. border-color) lives in. + const finalVars = new Map([ + ...extractPropertyInitialValues(css), + ...extractCssCustomProperties(css), + ]); + + let normalizedCss = normalizeUnoCssValues(css, finalVars); + if (finalVars.size > 0) { + normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => { + const colonIdx = match.indexOf(":"); + const propName = match.slice(0, colonIdx).trim(); + const finalValue = finalVars.get(propName); + return finalValue !== undefined + ? `${propName}: ${resolveCssVars(finalValue, finalVars)}` + : match; + }); + } + + return { css: normalizedCss, vars: finalVars }; +}; + +export const __testing__ = { + normalizeUnoCssForWebstudio, +}; + const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => { if ( styleDecl.property.startsWith("border-") === false || @@ -359,6 +474,37 @@ const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => { ); }; +const stylePropertyGroups: Record> = { + margin: new Set([ + "marginTop", + "marginRight", + "marginBottom", + "marginLeft", + "marginBlockStart", + "marginBlockEnd", + "marginInlineStart", + "marginInlineEnd", + ]), + padding: new Set([ + "paddingTop", + "paddingRight", + "paddingBottom", + "paddingLeft", + "paddingBlockStart", + "paddingBlockEnd", + "paddingInlineStart", + "paddingInlineEnd", + ]), +}; + +const getStylePropertyGroup = (property: string) => { + for (const [groupName, properties] of Object.entries(stylePropertyGroups)) { + if (properties.has(property)) { + return groupName; + } + } +}; + const parseTailwindClasses = async ( classes: string, userBreakpoints: Breakpoint[], @@ -406,23 +552,9 @@ const parseTailwindClasses = async ( }) .join(" "); const generated = await generator.generate(classes); - // use tailwind prefix instead of unocss one - const css = generated.css.replaceAll("--un-", "--tw-"); - // Normalize CSS custom property values: when the same var is declared in - // multiple utility-class rules, replace every occurrence with the value from - // the LAST declaration (the final cascaded value). This allows per-rule - // two-pass pre-collection to see the correct final value regardless of which - // rule a shorthand (e.g. border-color) lives in. - const finalVars = extractCssCustomProperties(css); - let normalizedCss = normalizeWind4Css(css, finalVars); - if (finalVars.size > 0) { - normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => { - const colonIdx = match.indexOf(":"); - const propName = match.slice(0, colonIdx).trim(); - const finalValue = finalVars.get(propName); - return finalValue !== undefined ? `${propName}: ${finalValue}` : match; - }); - } + const { css: normalizedCss, vars: finalVars } = normalizeUnoCssForWebstudio( + generated.css + ); let parsedStyles: StyleDecl[] = []; // @todo probably builtin in v4 if (normalizedCss.includes("border")) { @@ -534,10 +666,6 @@ const parseTailwindClasses = async ( { property: "flex-direction", value: { type: "keyword", value: "column" }, - }, - { - property: "align-items", - value: { type: "keyword", value: "start" }, } ); } @@ -618,6 +746,7 @@ export const generateFragmentFromTailwind = async ( const styles = new Map( fragment.styles.map((item) => [getStyleDeclKey(item), item]) ); + const preflightStyleDeclKeys = new Set(); const getLocalStyleSource = (instanceId: Instance["id"]) => { const styleSourceSelection = styleSourceSelections.get(instanceId); const lastStyleSourceId = styleSourceSelection?.values.at(-1); @@ -647,6 +776,7 @@ export const generateFragmentFromTailwind = async ( ) => { const localStyleSource = getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId); + const clearedPropertyGroups = new Set(); for (const parsedStyleDecl of newStyles) { const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint); // ignore unknown breakpoints @@ -666,8 +796,41 @@ export const generateFragmentFromTailwind = async ( if (skipExisting && styles.has(styleDeclKey)) { continue; } + if (skipExisting === false) { + const propertyGroup = getStylePropertyGroup(styleDecl.property); + const groupProperties = + propertyGroup === undefined + ? undefined + : stylePropertyGroups[propertyGroup]; + const groupKey = + propertyGroup === undefined + ? undefined + : `${styleDecl.styleSourceId}:${styleDecl.breakpointId}:${ + styleDecl.state ?? "" + }:${propertyGroup}`; + if ( + groupProperties !== undefined && + groupKey !== undefined && + clearedPropertyGroups.has(groupKey) === false + ) { + clearedPropertyGroups.add(groupKey); + for (const property of groupProperties) { + const groupStyleDeclKey = getStyleDeclKey({ + ...styleDecl, + property, + }); + if (preflightStyleDeclKeys.has(groupStyleDeclKey)) { + styles.delete(groupStyleDeclKey); + preflightStyleDeclKeys.delete(groupStyleDeclKey); + } + } + } + } styles.delete(styleDeclKey); styles.set(styleDeclKey, styleDecl); + if (skipExisting) { + preflightStyleDeclKeys.add(styleDeclKey); + } } }; diff --git a/apps/builder/vite.config.ts b/apps/builder/vite.config.ts index 04fef1f2207b..e54feb239fbd 100644 --- a/apps/builder/vite.config.ts +++ b/apps/builder/vite.config.ts @@ -42,6 +42,7 @@ export default defineConfig(({ mode }) => { plugins: [ remix({ presets: [vercelPreset()], + ignoredRouteFiles: ["**/.*", "**/*.test.{ts,tsx}"], future: { v3_lazyRouteDiscovery: false, v3_relativeSplatPath: false, diff --git a/packages/asset-uploader/src/schema.ts b/packages/asset-uploader/src/schema.ts index 6cc085f651e0..b036caac42bb 100644 --- a/packages/asset-uploader/src/schema.ts +++ b/packages/asset-uploader/src/schema.ts @@ -11,9 +11,3 @@ export const MaxSize: z.ZodEffects< .default(MAX_UPLOAD_SIZE) // user inputs the max value in mb and we transform it to bytes .transform(toBytes); - -export const MaxAssets: z.ZodEffects< - z.ZodDefault, - number, - string | undefined -> = z.string().default("50").transform(Number.parseFloat); diff --git a/packages/asset-uploader/src/upload.test.ts b/packages/asset-uploader/src/upload.test.ts index c94ed37cb4c5..304fb4a915cf 100644 --- a/packages/asset-uploader/src/upload.test.ts +++ b/packages/asset-uploader/src/upload.test.ts @@ -38,6 +38,18 @@ const ownershipHandler = db.get("Project", ({ request }) => { return json({ userId: "owner-1", workspaceId: null }); }); +const assetPkeyError = ({ + assetId, + projectId, +}: { + assetId: string; + projectId: string; +}) => ({ + code: "23505", + message: 'duplicate key value violates unique constraint "Asset_pkey"', + details: `Key (id, projectId)=(${assetId}, ${projectId}) already exists.`, +}); + describe("createUploadName", () => { test("counts assets instead of uploaded files for the project limit", async () => { let insertedFile: unknown; @@ -54,8 +66,10 @@ describe("createUploadName", () => { const url = new URL(request.url); expect(url.searchParams.get("uploaderProjectId")).toBe("eq.project-1"); expect(url.searchParams.get("status")).toBe("eq.UPLOADING"); + expect(url.searchParams.has("createdAt")).toBe(true); return empty({ headers: { "Content-Range": "*/0" } }); }), + db.get("Asset", () => json(null)), db.post("File", async ({ request }) => { insertedFile = await request.json(); return empty({ status: 201 }); @@ -91,6 +105,7 @@ describe("createUploadName", () => { test("throws when uploaded assets and recent uploads reach the limit", async () => { server.use( ownershipHandler, + db.get("Asset", () => json(null)), db.head("Asset", () => empty({ headers: { "Content-Range": "*/349" } })), db.head("File", () => empty({ headers: { "Content-Range": "*/1" } })) ); @@ -125,6 +140,7 @@ describe("createUploadName", () => { ), db.head("Asset", () => empty({ headers: { "Content-Range": "*/100" } })), db.head("File", () => empty({ headers: { "Content-Range": "*/0" } })), + db.get("Asset", () => json(null)), db.post("File", () => { insertedFile = true; return empty({ status: 201 }); @@ -147,4 +163,116 @@ describe("createUploadName", () => { expect(insertedFile).toBe(true); expect(ownerPlanCalls).toEqual(["team-owner"]); }); + + test("returns the existing upload name for an interrupted upload", async () => { + let existingFileUpdate: unknown; + let attemptedFileInsert = false; + let attemptedAssetInsert = false; + let attemptedAssetCount = false; + let attemptedUploadingCount = false; + const ownerPlanCalls: string[] = []; + const existingFileName = "old-photo_existing.png"; + + server.use( + ownershipHandler, + db.head("Asset", () => { + attemptedAssetCount = true; + return empty({ headers: { "Content-Range": "*/350" } }); + }), + db.head("File", () => { + attemptedUploadingCount = true; + return empty({ headers: { "Content-Range": "*/1" } }); + }), + db.post("File", () => { + attemptedFileInsert = true; + return empty({ status: 201 }); + }), + db.post("Asset", () => { + attemptedAssetInsert = true; + return empty({ status: 201 }); + }), + db.get("Asset", ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("id")).toBe("eq.asset-4"); + expect(url.searchParams.get("projectId")).toBe("eq.project-4"); + expect(url.searchParams.get("file.status")).toBe("eq.UPLOADING"); + expect(url.searchParams.get("file.isDeleted")).toBe("eq.false"); + expect(url.searchParams.get("file.uploaderProjectId")).toBe( + "eq.project-4" + ); + return json({ + name: existingFileName, + file: { status: "UPLOADING" }, + }); + }), + db.patch("File", async ({ request }) => { + const url = new URL(request.url); + expect(url.searchParams.get("name")).toBe(`eq.${existingFileName}`); + expect(url.searchParams.get("status")).toBe("eq.UPLOADING"); + expect(url.searchParams.get("isDeleted")).toBe("eq.false"); + expect(url.searchParams.get("uploaderProjectId")).toBe("eq.project-4"); + existingFileUpdate = await request.json(); + expect( + typeof (existingFileUpdate as { createdAt?: unknown }).createdAt + ).toBe("string"); + return json({ name: existingFileName }); + }) + ); + + const name = await createUploadName( + { + assetId: "asset-4", + projectId: "project-4", + type: "image/png", + filename: "renamed-photo.png", + }, + createContext({ maxAssetsPerProject: 0, ownerPlanCalls }) + ); + + expect(name).toBe(existingFileName); + expect(existingFileUpdate).toBeDefined(); + expect(attemptedFileInsert).toBe(false); + expect(attemptedAssetInsert).toBe(false); + expect(attemptedAssetCount).toBe(false); + expect(attemptedUploadingCount).toBe(false); + expect(ownerPlanCalls).toEqual([]); + }); + + test("keeps duplicate asset errors for already uploaded assets", async () => { + let attemptedFileUpdate = false; + + server.use( + ownershipHandler, + db.head("Asset", () => empty({ headers: { "Content-Range": "*/0" } })), + db.head("File", () => empty({ headers: { "Content-Range": "*/0" } })), + db.post("File", () => empty({ status: 201 })), + db.post("Asset", () => + json(assetPkeyError({ assetId: "asset-5", projectId: "project-5" }), { + status: 409, + }) + ), + db.delete("File", () => empty({ status: 204 })), + db.get("Asset", () => json(null)), + db.patch("File", () => { + attemptedFileUpdate = true; + return empty({ status: 204 }); + }) + ); + + await expect( + createUploadName( + { + assetId: "asset-5", + projectId: "project-5", + type: "image/png", + filename: "photo.png", + }, + createContext() + ) + ).rejects.toThrow( + 'duplicate key value violates unique constraint "Asset_pkey"' + ); + + expect(attemptedFileUpdate).toBe(false); + }); }); diff --git a/packages/asset-uploader/src/upload.ts b/packages/asset-uploader/src/upload.ts index 3cf329f4dbe9..677a50c587f8 100644 --- a/packages/asset-uploader/src/upload.ts +++ b/packages/asset-uploader/src/upload.ts @@ -17,6 +17,15 @@ type UploadData = { filename: string; }; +export class MaxAssetsPerProjectError extends Error { + constructor(maxAssetsPerProject: number) { + super( + `The maximum number of assets per project is ${maxAssetsPerProject}.` + ); + this.name = "MaxAssetsPerProjectError"; + } +} + const UPLOADING_STALE_TIMEOUT = 1000 * 60 * 30; // 30 minutes export const createUploadName = async ( @@ -34,6 +43,45 @@ export const createUploadName = async ( ); } + const existingUpload = await context.postgrest.client + .from("Asset") + .select( + ` + name, + file:File!inner(status) + ` + ) + .eq("id", assetId) + .eq("projectId", projectId) + .eq("file.status", "UPLOADING") + .eq("file.isDeleted", false) + .eq("file.uploaderProjectId", projectId) + .maybeSingle(); + if (existingUpload.error) { + throw new Error(existingUpload.error.message); + } + + if (existingUpload.data !== null) { + const fileUpdate = await context.postgrest.client + .from("File") + .update({ + // uploadFile uses createdAt as the reservation expiration timestamp + createdAt: new Date().toISOString(), + }) + .eq("name", existingUpload.data.name) + .eq("status", "UPLOADING") + .eq("isDeleted", false) + .eq("uploaderProjectId", projectId) + .select("name") + .maybeSingle(); + if (fileUpdate.error) { + throw new Error(fileUpdate.error.message); + } + if (fileUpdate.data !== null) { + return fileUpdate.data.name; + } + } + const { maxAssetsPerProject } = await getProjectPlanFeatures( projectId, context @@ -70,9 +118,7 @@ export const createUploadName = async ( * it's probable that the user can exceed the limit a little bit. * So it can be a little bit strange that the limit is 5 but the user already has 7. **/ - throw new Error( - `The maximum number of assets per project is ${maxAssetsPerProject}.` - ); + throw new MaxAssetsPerProjectError(maxAssetsPerProject); } /** diff --git a/packages/css-data/src/parse-css-value.test.ts b/packages/css-data/src/parse-css-value.test.ts index 0252be04062c..3b875822b12f 100644 --- a/packages/css-data/src/parse-css-value.test.ts +++ b/packages/css-data/src/parse-css-value.test.ts @@ -73,11 +73,44 @@ describe("Parse CSS value", () => { }); }); + test("Nested CSS math values", () => { + const value = + "clamp(1rem, calc(1rem + (2rem - 1rem) * ((100vw - 20rem) / (80rem - 20rem))), 2rem)"; + + expect(parseCssValue("font-size", value)).toEqual({ + type: "unparsed", + value, + }); + }); + + test("CSS numeric functions", () => { + expect(parseCssValue("width", "round(10px, 1px)")).toEqual({ + type: "unparsed", + value: "round(10px, 1px)", + }); + expect(parseCssValue("width", "abs(-10px)")).toEqual({ + type: "unparsed", + value: "abs(-10px)", + }); + expect(parseCssValue("width", "calc(pi * 1px)")).toEqual({ + type: "unparsed", + value: "calc(pi * 1px)", + }); + }); + test("Invalid function values", () => { expect(parseCssValue("width", "blur(4)")).toEqual({ type: "invalid", value: "blur(4)", }); + expect(parseCssValue("font-size", "clamp(foo)")).toEqual({ + type: "invalid", + value: "clamp(foo)", + }); + expect(parseCssValue("color", "abs(-10px)")).toEqual({ + type: "invalid", + value: "abs(-10px)", + }); }); }); diff --git a/packages/css-data/src/parse-css-value.ts b/packages/css-data/src/parse-css-value.ts index 3d0a6ff07d60..86ffa8005832 100644 --- a/packages/css-data/src/parse-css-value.ts +++ b/packages/css-data/src/parse-css-value.ts @@ -1,5 +1,6 @@ import { type CssNode, + definitionSyntax, type FunctionNode, generate, lexer, @@ -54,6 +55,91 @@ const splitRepeated = (nodes: CssNode[]) => { return lists; }; +const cssNumericFunctionNames = new Set([ + "calc", + "min", + "max", + "clamp", + "round", + "mod", + "rem", + "sin", + "cos", + "tan", + "asin", + "acos", + "atan", + "atan2", + "pow", + "sqrt", + "hypot", + "log", + "exp", + "abs", + "sign", +]); + +const cssMathConstants = new Set(["e", "pi", "infinity", "-infinity", "nan"]); + +const cssNumericTypeNames = new Set([ + "length", + "length-percentage", + "percentage", + "number", + "integer", + "angle", + "time", + "frequency", + "resolution", + "flex", + "alpha-value", +]); + +const canFallbackToCssMath = (ast: CssNode, syntax: string | undefined) => { + if (syntax === undefined) { + return false; + } + + let hasCssNumericType = false; + try { + definitionSyntax.walk(definitionSyntax.parse(syntax), (node) => { + if ( + node.type === "Type" && + "name" in node && + cssNumericTypeNames.has(node.name) + ) { + hasCssNumericType = true; + } + }); + } catch { + return false; + } + if (hasCssNumericType === false) { + return false; + } + + let hasCssNumericFunction = false; + let hasUnknownIdentifier = false; + walk(ast, (node) => { + if (node.type === "Function" && cssNumericFunctionNames.has(node.name)) { + hasCssNumericFunction = true; + } + if ( + node.type === "Identifier" && + cssMathConstants.has(node.name.toLowerCase()) === false + ) { + hasUnknownIdentifier = true; + } + }); + return hasCssNumericFunction && hasUnknownIdentifier === false; +}; + +const getSyntaxMatchErrorSyntax = (error: Error | null | undefined) => { + if (error != null && "syntax" in error && typeof error.syntax === "string") { + return error.syntax; + } +}; + // Because csstree parser has bugs we use CSSStyleValue to validate css properties if available // and fall back to csstree. export const isValidDeclaration = ( @@ -109,6 +195,10 @@ export const isValidDeclaration = ( // @todo remove after csstree fixes // - https://github.com/csstree/csstree/issues/246 // - https://github.com/csstree/csstree/issues/164 + if (typeof CSS !== "undefined" && CSS.supports(property, value)) { + return true; + } + if (typeof CSSStyleValue !== "undefined") { try { CSSStyleValue.parse(property, value); @@ -143,6 +233,17 @@ export const isValidDeclaration = ( return true; } + // css-tree does not fully validate modern CSS math with nested calc() + // operators, for example `font-size: clamp(... calc(... / ...) ...)`. + // Browser-valid values should be preserved as unparsed instead of stored as + // invalid values, which are intended for transient editor state. + if ( + matchResult.matched == null && + canFallbackToCssMath(ast, getSyntaxMatchErrorSyntax(matchResult.error)) + ) { + return true; + } + return matchResult.matched != null; }; diff --git a/packages/css-engine/src/core/prefixer.test.ts b/packages/css-engine/src/core/prefixer.test.ts index ca7eec40ba7a..cde93a438d7a 100644 --- a/packages/css-engine/src/core/prefixer.test.ts +++ b/packages/css-engine/src/core/prefixer.test.ts @@ -14,6 +14,33 @@ test("prefix background-clip", () => { ); }); +test("prefix text fill color for clipped text", () => { + expect( + prefixStyles( + new Map([ + ["color", { type: "keyword", value: "transparent" }], + [ + "background-clip", + { type: "layers", value: [{ type: "keyword", value: "text" }] }, + ], + ]) + ) + ).toEqual( + new Map([ + ["-webkit-text-fill-color", { type: "keyword", value: "transparent" }], + ["color", { type: "keyword", value: "transparent" }], + [ + "-webkit-background-clip", + { type: "layers", value: [{ type: "keyword", value: "text" }] }, + ], + [ + "background-clip", + { type: "layers", value: [{ type: "keyword", value: "text" }] }, + ], + ]) + ); +}); + test("prefix user-select", () => { expect( prefixStyles(new Map([["user-select", { type: "keyword", value: "none" }]])) diff --git a/packages/css-engine/src/core/prefixer.ts b/packages/css-engine/src/core/prefixer.ts index bb40662796cc..1fe2678ea5dd 100644 --- a/packages/css-engine/src/core/prefixer.ts +++ b/packages/css-engine/src/core/prefixer.ts @@ -1,7 +1,21 @@ import type { StyleMap } from "./rules"; +import type { CssProperty, StyleValue } from "../schema"; + +const isKeyword = (value: StyleValue, keyword: string) => { + if (value.type === "keyword") { + return value.value === keyword; + } + if (value.type === "layers") { + return value.value.some((layer) => isKeyword(layer, keyword)); + } + return false; +}; export const prefixStyles = (styleMap: StyleMap) => { const newStyleMap: StyleMap = new Map(); + const backgroundClip = styleMap.get("background-clip"); + const hasTextBackgroundClip = + backgroundClip !== undefined && isKeyword(backgroundClip, "text"); for (const [property, value] of styleMap) { // chrome started to support unprefixed background-clip in December 2023 // https://caniuse.com/background-clip-text @@ -25,6 +39,13 @@ export const prefixStyles = (styleMap: StyleMap) => { if (property === "backdrop-filter") { newStyleMap.set("-webkit-backdrop-filter", value); } + if ( + property === "color" && + hasTextBackgroundClip && + isKeyword(value, "transparent") + ) { + newStyleMap.set("-webkit-text-fill-color" as CssProperty, value); + } // Safari and FF do not support this property and strip it from the CSS // For polyfill to work we need to set it as a CSS property diff --git a/packages/design-system/src/components/color-picker.test.ts b/packages/design-system/src/components/color-picker.test.ts index 4be243b39d9b..213e50515714 100644 --- a/packages/design-system/src/components/color-picker.test.ts +++ b/packages/design-system/src/components/color-picker.test.ts @@ -2,7 +2,13 @@ import { describe, test, expect } from "vitest"; import type { ColorSpace } from "hdr-color-input"; import type { ColorValue } from "@webstudio-is/css-engine"; import { __testing__ } from "./color-picker"; -const { cssStringToStyleValue, shouldCommitColorChange } = __testing__; +const { + cssStringToStyleValue, + shouldCommitColorChange, + shouldHandleColorInputChange, + styleValueToColorInputColorSpace, + styleValueToColorInputValue, +} = __testing__; describe("shouldCommitColorChange", () => { test("returns false when values serialize identically", () => { @@ -44,6 +50,26 @@ describe("shouldCommitColorChange", () => { }); }); +describe("shouldHandleColorInputChange", () => { + test("ignores color input changes while picker is closed", () => { + expect( + shouldHandleColorInputChange({ disabled: false, isOpen: false }) + ).toBe(false); + }); + + test("handles color input changes while picker is open", () => { + expect( + shouldHandleColorInputChange({ disabled: false, isOpen: true }) + ).toBe(true); + }); + + test("ignores color input changes while disabled", () => { + expect(shouldHandleColorInputChange({ disabled: true, isOpen: true })).toBe( + false + ); + }); +}); + // All ColorSpace values from hdr-color-input and their expected CSS input strings // (as would emit in its change event). describe("cssStringToStyleValue", () => { @@ -203,3 +229,86 @@ describe("cssStringToStyleValue", () => { }); }); }); + +describe("styleValueToColorInputColorSpace", () => { + test("keeps pasted hex values in hex mode", () => { + expect( + styleValueToColorInputColorSpace({ + type: "color", + colorSpace: "hex", + components: [1, 0.3412, 0.2], + alpha: 1, + }) + ).toBe("hex"); + }); + + test("keeps rgb values in srgb mode", () => { + expect( + styleValueToColorInputColorSpace({ + type: "color", + colorSpace: "srgb", + components: [1, 0.3412, 0.2], + alpha: 1, + }) + ).toBe("srgb"); + expect( + styleValueToColorInputColorSpace({ + type: "rgb", + r: 255, + g: 87, + b: 51, + alpha: 1, + }) + ).toBe("srgb"); + }); + + test("maps internal color space names to color-input names", () => { + expect( + styleValueToColorInputColorSpace({ + type: "color", + colorSpace: "p3", + components: [0.4, 0.6, 0.3], + alpha: 1, + }) + ).toBe("display-p3"); + expect( + styleValueToColorInputColorSpace({ + type: "color", + colorSpace: "a98rgb", + components: [0.4, 0.6, 0.3], + alpha: 1, + }) + ).toBe("a98-rgb"); + }); + + test("uses srgb mode for color keywords", () => { + expect( + styleValueToColorInputColorSpace({ + type: "keyword", + value: "red", + }) + ).toBe("srgb"); + }); +}); + +describe("styleValueToColorInputValue", () => { + test("converts color keywords to concrete rgb input values", () => { + expect( + styleValueToColorInputValue({ + type: "keyword", + value: "red", + }) + ).toBe("rgb(255, 0, 0)"); + }); + + test("keeps color values in their serialized format", () => { + expect( + styleValueToColorInputValue({ + type: "color", + colorSpace: "hex", + components: [1, 0.3412, 0.2], + alpha: 1, + }) + ).toBe("#ff5733"); + }); +}); diff --git a/packages/design-system/src/components/color-picker.tsx b/packages/design-system/src/components/color-picker.tsx index 86bcf7b41784..4e46a704bf29 100644 --- a/packages/design-system/src/components/color-picker.tsx +++ b/packages/design-system/src/components/color-picker.tsx @@ -73,6 +73,36 @@ const cssStringToStyleValue = ( }; }; +const styleValueToColorInputColorSpace = ( + value: StyleValue +): ColorSpace | undefined => { + if (value.type === "rgb") { + return "srgb"; + } + if (value.type === "color") { + switch (value.colorSpace) { + case "p3": + return "display-p3"; + case "a98rgb": + return "a98-rgb"; + default: + return value.colorSpace; + } + } + if (parseColor(toValue(value)) !== undefined) { + return "srgb"; + } +}; + +const styleValueToColorInputValue = (value: StyleValue) => { + const valueString = toValue(value); + if (value.type === "color" || value.type === "rgb") { + return valueString; + } + const parsedColor = parseColor(valueString); + return parsedColor === undefined ? valueString : serializeColor(parsedColor); +}; + // ─── ColorThumb ────────────────────────────────────────────────────────────── const borderColorSwatch = parseColor(rawTheme.colors.borderColorSwatch); @@ -157,13 +187,26 @@ const shouldCommitColorChange = ( return toValue(previousValue) !== toValue(nextValue); }; +const shouldHandleColorInputChange = ({ + disabled, + isOpen, +}: { + disabled: boolean; + isOpen: boolean; +}) => { + return disabled === false && isOpen; +}; + // Renders with its built-in trigger chip, hiding the text input. // The chip opens the panel natively via the Popover API. // Our own ColorThumb is rendered on top (pointer-events: none) so that clicks // pass through to the real chip underneath. export const __testing__ = { cssStringToStyleValue, + styleValueToColorInputColorSpace, + styleValueToColorInputValue, shouldCommitColorChange, + shouldHandleColorInputChange, }; export const ColorPicker = ({ @@ -200,6 +243,8 @@ export const ColorPicker = ({ }; const colorString = toValue(value); + const colorSpace = styleValueToColorInputColorSpace(value); + const colorInputValue = styleValueToColorInputValue(value); const overrideContrast = useCallback(() => { const colorInputElement = colorInputRef.current; @@ -239,11 +284,16 @@ export const ColorPicker = ({ // Sync external value changes into the web component. useEffect(() => { const colorInputElement = colorInputRef.current; - if (colorInputElement && colorInputElement.value !== colorString) { - colorInputElement.value = colorString; + if (colorInputElement && colorSpace !== undefined) { + colorInputElement.colorspace = colorSpace; + } else { + colorInputElement?.removeAttribute("colorspace"); + } + if (colorInputElement && colorInputElement.value !== colorInputValue) { + colorInputElement.value = colorInputValue; } overrideContrast(); - }, [colorString, overrideContrast]); + }, [colorSpace, colorInputValue, overrideContrast]); // Wire up change / open / close events. useEffect(() => { @@ -275,7 +325,7 @@ export const ColorPicker = ({ colorInputElement.addEventListener( "change", (event: Event) => { - if (disabled) { + if (shouldHandleColorInputChange({ disabled, isOpen }) === false) { return; } const { value, colorspace } = (event as CustomEvent) @@ -300,6 +350,8 @@ export const ColorPicker = ({ // before any change event fires (the component's own JS sets --contrast // based on raw color only, ignoring alpha). isOpen = true; + lastStyleValue = callbacksRef.current.value; + lastCommittedStyleValue = callbacksRef.current.value; callbacksRef.current.disableCanvasPointerEvents(); document.body.style.userSelect = "none"; callbacksRef.current.onOpenChange?.(true); @@ -380,7 +432,8 @@ export const ColorPicker = ({ {disabled === false && ( diff --git a/packages/plans/src/plan-features.test.ts b/packages/plans/src/plan-features.test.ts index 00db820d42f5..e66934431ccc 100644 --- a/packages/plans/src/plan-features.test.ts +++ b/packages/plans/src/plan-features.test.ts @@ -36,6 +36,10 @@ describe("parsePlansEnv", () => { { name: "Workspaces", features: { ...fullFeatures, maxWorkspaces: 10 } }, ]); + test("default plan allows 100 assets per project", () => { + expect(defaultPlanFeatures.maxAssetsPerProject).toBe(100); + }); + test("returns empty map for empty JSON array", () => { expect(parsePlansEnv("[]").size).toBe(0); }); diff --git a/packages/plans/src/plan-features.ts b/packages/plans/src/plan-features.ts index ef06b01c8d36..af8217975b42 100644 --- a/packages/plans/src/plan-features.ts +++ b/packages/plans/src/plan-features.ts @@ -44,7 +44,7 @@ export const defaultPlanFeatures: PlanFeatures = { maxDailyPublishesPerUser: 10, maxWorkspaces: 1, maxProjectsAllowedPerUser: 100, - maxAssetsPerProject: 50, + maxAssetsPerProject: 100, seatsIncluded: 0, maxSeatsPerWorkspace: 0, }; diff --git a/packages/project/src/db/build-patch-permissions.test.ts b/packages/project/src/db/build-patch-permissions.test.ts index 58eb396a257f..6e60662add12 100644 --- a/packages/project/src/db/build-patch-permissions.test.ts +++ b/packages/project/src/db/build-patch-permissions.test.ts @@ -19,6 +19,20 @@ describe("getRequiredPermitForBuildPatchTransaction", () => { ).toBe("edit"); }); + test("allows content block instance edits with edit permit", () => { + expect( + getRequiredPermitForBuildPatchTransaction( + transaction("instances", [ + { + op: "replace", + path: ["instance-1", "children"], + value: [{ type: "text", value: "Title" }], + }, + ]) + ) + ).toBe("edit"); + }); + test("requires build permit for style edits", () => { expect( getRequiredPermitForBuildPatchTransaction(transaction("styles")) diff --git a/packages/project/src/db/build-patch-permissions.ts b/packages/project/src/db/build-patch-permissions.ts index 008f53d39a6e..187898388834 100644 --- a/packages/project/src/db/build-patch-permissions.ts +++ b/packages/project/src/db/build-patch-permissions.ts @@ -1,7 +1,7 @@ import type { AuthPermit } from "@webstudio-is/trpc-interface/index.server"; import type { BuildPatchTransaction } from "./build-patch-core"; -const contentNamespaces = new Set(["props"]); +const contentNamespaces = new Set(["instances", "props"]); export const getRequiredPermitForBuildPatchTransaction = ( transaction: BuildPatchTransaction diff --git a/packages/sdk/src/resource-loader.test.ts b/packages/sdk/src/resource-loader.test.ts index 1d11d305a940..0354fb9edc13 100644 --- a/packages/sdk/src/resource-loader.test.ts +++ b/packages/sdk/src/resource-loader.test.ts @@ -1,4 +1,12 @@ -import { describe, expect, test, beforeEach, vi, type Mock } from "vitest"; +import { + afterEach, + describe, + expect, + test, + beforeEach, + vi, + type Mock, +} from "vitest"; import { loadResource } from "./resource-loader"; import type { ResourceRequest } from "./schema/resources"; @@ -13,6 +21,10 @@ describe("loadResource", () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + test("should successfully fetch a resource and return a JSON response", async () => { const mockResponse = new Response(JSON.stringify({ key: "value" }), { status: 200, @@ -156,4 +168,37 @@ describe("loadResource", () => { ]), }); }); + + test("should log failed resource responses as info", async () => { + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + const mockResponse = new Response("not found", { + status: 404, + }); + mockFetch.mockResolvedValue(mockResponse); + + const resourceRequest: ResourceRequest = { + name: "resource", + url: "https://example.com/resource", + searchParams: [], + method: "get", + headers: [], + body: undefined, + }; + + const result = await loadResource(mockFetch, resourceRequest); + + expect(result).toEqual({ + data: "not found", + ok: false, + status: 404, + statusText: "", + }); + expect(consoleError).not.toHaveBeenCalled(); + expect(consoleInfo).toHaveBeenCalledWith( + 'Failed to load resource: https://example.com/resource - 404: "not found"' + ); + }); }); diff --git a/packages/sdk/src/resource-loader.ts b/packages/sdk/src/resource-loader.ts index 7aa7a7cd187d..f7b41892172c 100644 --- a/packages/sdk/src/resource-loader.ts +++ b/packages/sdk/src/resource-loader.ts @@ -66,7 +66,7 @@ export const loadResource = async ( } if (!response.ok) { - console.error( + console.info( `Failed to load resource: ${href} - ${response.status}: ${JSON.stringify(data).slice(0, 300)}` ); }