diff --git a/apps/builder/app/builder/shared/binding-popover.test.ts b/apps/builder/app/builder/shared/binding-popover.test.ts new file mode 100644 index 0000000000..8b611ba657 --- /dev/null +++ b/apps/builder/app/builder/shared/binding-popover.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from "@jest/globals"; +import { evaluateExpressionWithinScope } from "./binding-popover"; +import { encodeDataSourceVariable } from "@webstudio-is/sdk"; + +test("evaluateExpressionWithinScope works", () => { + const variableName = "jsonVariable"; + const encVariableName = encodeDataSourceVariable(variableName); + const variableValue = 1; + + expect( + evaluateExpressionWithinScope(`${encVariableName} + ${encVariableName}`, { + [encVariableName]: variableValue, + }) + ).toEqual(2); +}); diff --git a/apps/builder/app/builder/shared/binding-popover.tsx b/apps/builder/app/builder/shared/binding-popover.tsx index 70e5581745..6c21f12fb1 100644 --- a/apps/builder/app/builder/shared/binding-popover.tsx +++ b/apps/builder/app/builder/shared/binding-popover.tsx @@ -46,23 +46,21 @@ import { type EditorApi, } from "./expression-editor"; import { useSideOffset } from "./floating-panel"; -import { $dataSourceVariables } from "~/shared/nano-states"; +import { $dataSourceVariables, computeExpression } from "~/shared/nano-states"; export const evaluateExpressionWithinScope = ( expression: string, scope: Record ) => { - let expressionWithScope = ""; + const variables = new Map(); for (const [name, value] of Object.entries(scope)) { - expressionWithScope += `const ${name} = ${JSON.stringify(value)};\n`; - } - expressionWithScope += `return (${expression})`; - try { - const fn = new Function(expressionWithScope); - return fn(); - } catch { - // + const decodedName = decodeDataSourceVariable(name); + if (decodedName) { + variables.set(decodedName, value); + } } + + return computeExpression(expression, variables); }; const BindingPanel = ({ @@ -447,6 +445,7 @@ export const BindingPopover = ({ value, scope ); + onRemove(evaluatedValue); preventedClosing.current = false; hasUnsavedChange.current = false; diff --git a/apps/builder/app/shared/nano-states/props.test.ts b/apps/builder/app/shared/nano-states/props.test.ts index e077c9346c..03d7b90296 100644 --- a/apps/builder/app/shared/nano-states/props.test.ts +++ b/apps/builder/app/shared/nano-states/props.test.ts @@ -2,7 +2,7 @@ import { beforeEach, expect, test } from "@jest/globals"; import { cleanStores } from "nanostores"; import { createDefaultPages } from "@webstudio-is/project-build"; import { setEnv } from "@webstudio-is/feature-flags"; -import type { Instance } from "@webstudio-is/sdk"; +import { encodeDataSourceVariable, type Instance } from "@webstudio-is/sdk"; import { collectionComponent, textContentAttribute, @@ -993,3 +993,20 @@ test("prefill default system variable value", () => { test("compute expression when invalid syntax", () => { expect(computeExpression("https://github.com", new Map())).toEqual(undefined); }); + +test("compute literal expression when variable is json object", () => { + const variableName = "jsonVariable"; + const encVariableName = encodeDataSourceVariable(variableName); + const jsonObject = { hello: "world", subObject: { world: "hello" } }; + const variables = new Map([[variableName, jsonObject]]); + const expression = "`${" + encVariableName + "}`"; + + expect(computeExpression(expression, variables)).toEqual( + JSON.stringify(jsonObject) + ); + + const subObjectExpression = "`${" + encVariableName + ".subObject}`"; + expect(computeExpression(subObjectExpression, variables)).toEqual( + JSON.stringify(jsonObject.subObject) + ); +}); diff --git a/apps/builder/app/shared/nano-states/props.ts b/apps/builder/app/shared/nano-states/props.ts index 1b017d1ccd..a402999cd5 100644 --- a/apps/builder/app/shared/nano-states/props.ts +++ b/apps/builder/app/shared/nano-states/props.ts @@ -9,8 +9,10 @@ import type { ImageAsset, } from "@webstudio-is/sdk"; import { + createJsonStringifyProxy, decodeDataSourceVariable, encodeDataSourceVariable, + isPlainObject, transpileExpression, } from "@webstudio-is/sdk"; import { @@ -223,7 +225,15 @@ export const computeExpression = ( code += `let ${identifier} = _variables.get("${id}");\n`; } code += `return (${transpiled})`; - const result = new Function("_variables", code)(variables); + + const proxiedVariables = new Map( + [...variables.entries()].map(([key, value]) => [ + key, + isPlainObject(value) ? createJsonStringifyProxy(value) : value, + ]) + ); + + const result = new Function("_variables", code)(proxiedVariables); return result; } catch { // empty block diff --git a/fixtures/ssg/app/__generated__/[another-page]._index.tsx b/fixtures/ssg/app/__generated__/[another-page]._index.tsx index 35b02f6386..b7bb680030 100644 --- a/fixtures/ssg/app/__generated__/[another-page]._index.tsx +++ b/fixtures/ssg/app/__generated__/[another-page]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Heading as Heading, diff --git a/fixtures/ssg/app/__generated__/_index.tsx b/fixtures/ssg/app/__generated__/_index.tsx index cd60546692..26bd19c14c 100644 --- a/fixtures/ssg/app/__generated__/_index.tsx +++ b/fixtures/ssg/app/__generated__/_index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Heading as Heading, diff --git a/fixtures/webstudio-cloudflare-template/app/__generated__/[another-page]._index.tsx b/fixtures/webstudio-cloudflare-template/app/__generated__/[another-page]._index.tsx index 51efe7c00a..6d5a498a6c 100644 --- a/fixtures/webstudio-cloudflare-template/app/__generated__/[another-page]._index.tsx +++ b/fixtures/webstudio-cloudflare-template/app/__generated__/[another-page]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body } from "@webstudio-is/sdk-components-react-remix"; import { Heading as Heading } from "@webstudio-is/sdk-components-react"; diff --git a/fixtures/webstudio-cloudflare-template/app/__generated__/_index.tsx b/fixtures/webstudio-cloudflare-template/app/__generated__/_index.tsx index 88980342e4..58e6855e5e 100644 --- a/fixtures/webstudio-cloudflare-template/app/__generated__/_index.tsx +++ b/fixtures/webstudio-cloudflare-template/app/__generated__/_index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Link as Link, diff --git a/fixtures/webstudio-custom-template/app/__generated__/[script-test]._index.tsx b/fixtures/webstudio-custom-template/app/__generated__/[script-test]._index.tsx index 2416c17c2e..1c00232bbd 100644 --- a/fixtures/webstudio-custom-template/app/__generated__/[script-test]._index.tsx +++ b/fixtures/webstudio-custom-template/app/__generated__/[script-test]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Link as Link, diff --git a/fixtures/webstudio-custom-template/app/__generated__/[sitemap.xml]._index.tsx b/fixtures/webstudio-custom-template/app/__generated__/[sitemap.xml]._index.tsx index 8319b38d04..786df3d03e 100644 --- a/fixtures/webstudio-custom-template/app/__generated__/[sitemap.xml]._index.tsx +++ b/fixtures/webstudio-custom-template/app/__generated__/[sitemap.xml]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { XmlNode } from "@webstudio-is/sdk-components-react"; export const siteName = "Fixture Site"; diff --git a/fixtures/webstudio-custom-template/app/__generated__/[world]._index.tsx b/fixtures/webstudio-custom-template/app/__generated__/[world]._index.tsx index 01fb47a87f..9ccb72d281 100644 --- a/fixtures/webstudio-custom-template/app/__generated__/[world]._index.tsx +++ b/fixtures/webstudio-custom-template/app/__generated__/[world]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body } from "@webstudio-is/sdk-components-react-remix"; import { Heading as Heading } from "@webstudio-is/sdk-components-react"; diff --git a/fixtures/webstudio-custom-template/app/__generated__/_index.tsx b/fixtures/webstudio-custom-template/app/__generated__/_index.tsx index 1fb006fb8f..358631c4c8 100644 --- a/fixtures/webstudio-custom-template/app/__generated__/_index.tsx +++ b/fixtures/webstudio-custom-template/app/__generated__/_index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Link as Link, diff --git a/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/[another-page]._index.tsx b/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/[another-page]._index.tsx index 51efe7c00a..6d5a498a6c 100644 --- a/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/[another-page]._index.tsx +++ b/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/[another-page]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body } from "@webstudio-is/sdk-components-react-remix"; import { Heading as Heading } from "@webstudio-is/sdk-components-react"; diff --git a/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/_index.tsx b/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/_index.tsx index 88980342e4..58e6855e5e 100644 --- a/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/_index.tsx +++ b/fixtures/webstudio-remix-netlify-edge-functions/app/__generated__/_index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Link as Link, diff --git a/fixtures/webstudio-remix-netlify-functions/app/__generated__/[another-page]._index.tsx b/fixtures/webstudio-remix-netlify-functions/app/__generated__/[another-page]._index.tsx index 51efe7c00a..6d5a498a6c 100644 --- a/fixtures/webstudio-remix-netlify-functions/app/__generated__/[another-page]._index.tsx +++ b/fixtures/webstudio-remix-netlify-functions/app/__generated__/[another-page]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body } from "@webstudio-is/sdk-components-react-remix"; import { Heading as Heading } from "@webstudio-is/sdk-components-react"; diff --git a/fixtures/webstudio-remix-netlify-functions/app/__generated__/_index.tsx b/fixtures/webstudio-remix-netlify-functions/app/__generated__/_index.tsx index 88980342e4..58e6855e5e 100644 --- a/fixtures/webstudio-remix-netlify-functions/app/__generated__/_index.tsx +++ b/fixtures/webstudio-remix-netlify-functions/app/__generated__/_index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Link as Link, diff --git a/fixtures/webstudio-remix-vercel/.webstudio/data.json b/fixtures/webstudio-remix-vercel/.webstudio/data.json index dfd08f0f38..33f754bda1 100644 --- a/fixtures/webstudio-remix-vercel/.webstudio/data.json +++ b/fixtures/webstudio-remix-vercel/.webstudio/data.json @@ -1,23 +1,22 @@ { "build": { - "id": "3dce3f4f-3c20-46e5-a885-d1b543db6237", + "id": "0f80ffe8-7059-4577-8ef3-232f3aded028", "projectId": "cddc1d44-af37-4cb6-a430-d300cf6f932d", - "version": 257, - "createdAt": "2024-05-30T16:07:53.141", - "updatedAt": "2024-05-30T16:07:53.141+00:00", + "version": 300, + "createdAt": "2024-08-07T15:25:00.822", + "updatedAt": "2024-08-07T15:25:00.822+00:00", "pages": { "meta": { "siteName": "KittyGuardedZone", "faviconAssetId": "88d5e2ff-b8f2-4899-aaf8-dde4ade6da10", "code": "\n" }, - "settings": { - "atomicStyles": true - }, "homePage": { "id": "7Db64ZXgYiRqKSQNR-qTQ", "name": "Home", "title": "\"The Ultimate Cat Protection Zone\"", + "rootInstanceId": "On9cvWCxr5rdZtY9O1Bv0", + "systemDataSourceId": "u9wtUkt2-E0RDaljl_I6E", "meta": { "description": "\"Dive into the world of felines and discover why some whiskers are best left untouched. From intriguing cat behaviors to protective measures, \\nKittyGuardedZone is your go-to hub for all things 'hands-off' in the cat realm.\"", "socialImageAssetId": "cd939c56-bcdd-4e64-bd9c-567a9bccd3da", @@ -28,61 +27,61 @@ } ] }, - "rootInstanceId": "On9cvWCxr5rdZtY9O1Bv0", - "path": "", - "systemDataSourceId": "u9wtUkt2-E0RDaljl_I6E" + "path": "" }, "pages": [ { "id": "xfvB4UThQXmQ_OubPYrkg", "name": "radix excluded from the search", "title": "\"Radix Revelations: Unraveling the Feline Mystique\"", + "rootInstanceId": "uKWGyE9JY3cPwY-xI9vk6", + "systemDataSourceId": "GqDH_PSDF_8QXqPW2A0un", "meta": { "description": "\"Delve deep into the radix roots of feline behaviors. At KittyNoTouchy, we dissect the core essence, or 'radix', of what makes cats the enigmatic creatures they are. Join us as we explore the radix of their instincts, habits, and quirks.\"", "excludePageFromSearch": "true", "socialImageAssetId": "88d5e2ff-b8f2-4899-aaf8-dde4ade6da10" }, - "rootInstanceId": "uKWGyE9JY3cPwY-xI9vk6", - "path": "/radix", - "systemDataSourceId": "GqDH_PSDF_8QXqPW2A0un" + "path": "/radix" }, { "id": "szYLvBduHPmbtqQKCDY0b", "name": "RouteWithSymbols", "title": "\"RouteWithSymbols\"", + "rootInstanceId": "EDEfpMPRqDejthtwkH7ws", + "systemDataSourceId": "UiZtSC5BK8Ub-Ow7-XOdW", "meta": { "description": "\"\"" }, - "rootInstanceId": "EDEfpMPRqDejthtwkH7ws", - "path": "/_route_with_symbols_", - "systemDataSourceId": "UiZtSC5BK8Ub-Ow7-XOdW" + "path": "/_route_with_symbols_" }, { "id": "U1tRJl2ERr8_OFe0g9cN_", "name": "form", "title": "\"form\"", + "rootInstanceId": "a-4nDFkaWy4px1fn38XWJ", + "systemDataSourceId": "-CX9SNsPSue42RkfxjL8_", "meta": { "description": "\"\"" }, - "rootInstanceId": "a-4nDFkaWy4px1fn38XWJ", - "path": "/form", - "systemDataSourceId": "-CX9SNsPSue42RkfxjL8_" + "path": "/form" }, { "id": "-J9I4Oo6mONfQlf_3-OqG", "name": "heading-with-id", "title": "\"heading-with-id\"", + "rootInstanceId": "O-ljaGZQ0iRNTlEshMkgE", + "systemDataSourceId": "Q6XrK4-ZZUdXsocdiWqVk", "meta": { "description": "\"\"" }, - "rootInstanceId": "O-ljaGZQ0iRNTlEshMkgE", - "path": "/heading-with-id", - "systemDataSourceId": "Q6XrK4-ZZUdXsocdiWqVk" + "path": "/heading-with-id" }, { "id": "TS39WeBd0Qr3QgbSlzdf2", "name": "resources", "title": "\"resources\"", + "rootInstanceId": "AWY2qZfpbykoiWELeJhse", + "systemDataSourceId": "keP0A66msOpEL6HbrC9OJ", "meta": { "description": "\"\"", "excludePageFromSearch": "false", @@ -94,14 +93,14 @@ } ] }, - "rootInstanceId": "AWY2qZfpbykoiWELeJhse", - "path": "/resources", - "systemDataSourceId": "keP0A66msOpEL6HbrC9OJ" + "path": "/resources" }, { "id": "Pnz-BxUm6XmvFygk_XdEy", "name": "Nested Page", "title": "\"Nested Page\"", + "rootInstanceId": "L0ZXd5F9xk9Rsl9ORzIkJ", + "systemDataSourceId": "8qqAbq6TEA3ccoCagjlZL", "meta": { "description": "\"\"", "excludePageFromSearch": "false", @@ -113,9 +112,28 @@ } ] }, - "rootInstanceId": "L0ZXd5F9xk9Rsl9ORzIkJ", - "path": "/nested-page", - "systemDataSourceId": "8qqAbq6TEA3ccoCagjlZL" + "path": "/nested-page" + }, + { + "id": "9xlwLSHxuk8HmS3-EEGcf", + "name": "expressions", + "title": "\"Untitled\"", + "rootInstanceId": "MTUwXTcUJcWmr9iGvdcoH", + "systemDataSourceId": "8tTIagkgzmdy4T4qzKXqd", + "meta": { + "description": "\"\"", + "excludePageFromSearch": "true", + "language": "\"\"", + "socialImageUrl": "\"\"", + "status": "200", + "redirect": "\"\"", + "documentType": "html", + "custom": [] + }, + "marketplace": { + "include": false + }, + "path": "/expressions" } ], "folders": [ @@ -130,7 +148,8 @@ "U1tRJl2ERr8_OFe0g9cN_", "-J9I4Oo6mONfQlf_3-OqG", "TS39WeBd0Qr3QgbSlzdf2", - "42cWhASQ3tTtKDnsvzhUF" + "42cWhASQ3tTtKDnsvzhUF", + "9xlwLSHxuk8HmS3-EEGcf" ] }, { @@ -677,18 +696,6 @@ } } ], - [ - "ZLyayr_Puk3HM35c44F2J:UoTkWyaFuTYJihS3MFYK5:overflow:", - { - "breakpointId": "UoTkWyaFuTYJihS3MFYK5", - "styleSourceId": "ZLyayr_Puk3HM35c44F2J", - "property": "overflow", - "value": { - "type": "keyword", - "value": "hidden" - } - } - ], [ "ZLyayr_Puk3HM35c44F2J:UoTkWyaFuTYJihS3MFYK5:fontSize:", { @@ -1020,18 +1027,6 @@ } } ], - [ - "4pgxDxhhX5AvMmL4olZw0:UoTkWyaFuTYJihS3MFYK5:overflow:", - { - "breakpointId": "UoTkWyaFuTYJihS3MFYK5", - "styleSourceId": "4pgxDxhhX5AvMmL4olZw0", - "property": "overflow", - "value": { - "type": "keyword", - "value": "hidden" - } - } - ], [ "4pgxDxhhX5AvMmL4olZw0:UoTkWyaFuTYJihS3MFYK5:fontSize:", { @@ -1363,18 +1358,6 @@ } } ], - [ - "EG1HUMyU4Rw4xD2pnGi28:UoTkWyaFuTYJihS3MFYK5:overflow:", - { - "breakpointId": "UoTkWyaFuTYJihS3MFYK5", - "styleSourceId": "EG1HUMyU4Rw4xD2pnGi28", - "property": "overflow", - "value": { - "type": "keyword", - "value": "hidden" - } - } - ], [ "EG1HUMyU4Rw4xD2pnGi28:UoTkWyaFuTYJihS3MFYK5:fontSize:", { @@ -1450,6 +1433,78 @@ "value": "block" } } + ], + [ + "ZLyayr_Puk3HM35c44F2J:UoTkWyaFuTYJihS3MFYK5:overflowX:", + { + "breakpointId": "UoTkWyaFuTYJihS3MFYK5", + "styleSourceId": "ZLyayr_Puk3HM35c44F2J", + "property": "overflowX", + "value": { + "type": "keyword", + "value": "hidden" + } + } + ], + [ + "ZLyayr_Puk3HM35c44F2J:UoTkWyaFuTYJihS3MFYK5:overflowY:", + { + "breakpointId": "UoTkWyaFuTYJihS3MFYK5", + "styleSourceId": "ZLyayr_Puk3HM35c44F2J", + "property": "overflowY", + "value": { + "type": "keyword", + "value": "hidden" + } + } + ], + [ + "4pgxDxhhX5AvMmL4olZw0:UoTkWyaFuTYJihS3MFYK5:overflowX:", + { + "breakpointId": "UoTkWyaFuTYJihS3MFYK5", + "styleSourceId": "4pgxDxhhX5AvMmL4olZw0", + "property": "overflowX", + "value": { + "type": "keyword", + "value": "hidden" + } + } + ], + [ + "4pgxDxhhX5AvMmL4olZw0:UoTkWyaFuTYJihS3MFYK5:overflowY:", + { + "breakpointId": "UoTkWyaFuTYJihS3MFYK5", + "styleSourceId": "4pgxDxhhX5AvMmL4olZw0", + "property": "overflowY", + "value": { + "type": "keyword", + "value": "hidden" + } + } + ], + [ + "EG1HUMyU4Rw4xD2pnGi28:UoTkWyaFuTYJihS3MFYK5:overflowX:", + { + "breakpointId": "UoTkWyaFuTYJihS3MFYK5", + "styleSourceId": "EG1HUMyU4Rw4xD2pnGi28", + "property": "overflowX", + "value": { + "type": "keyword", + "value": "hidden" + } + } + ], + [ + "EG1HUMyU4Rw4xD2pnGi28:UoTkWyaFuTYJihS3MFYK5:overflowY:", + { + "breakpointId": "UoTkWyaFuTYJihS3MFYK5", + "styleSourceId": "EG1HUMyU4Rw4xD2pnGi28", + "property": "overflowY", + "value": { + "type": "keyword", + "value": "hidden" + } + } ] ], "styleSources": [ @@ -2163,6 +2218,16 @@ "type": "expression", "value": "$ws$dataSource$MShox7Ngja3P8_t6XGMBw.name" } + ], + [ + "te2oQYASF3qsGMGekIcsF", + { + "id": "te2oQYASF3qsGMGekIcsF", + "instanceId": "sHn4Mh7DJ5X0RiETB-zVc", + "name": "code", + "type": "expression", + "value": "``" + } ] ], "dataSources": [ @@ -2227,64 +2292,98 @@ [ "u9wtUkt2-E0RDaljl_I6E", { + "type": "parameter", "id": "u9wtUkt2-E0RDaljl_I6E", "scopeInstanceId": "On9cvWCxr5rdZtY9O1Bv0", - "name": "system", - "type": "parameter" + "name": "system" } ], [ "GqDH_PSDF_8QXqPW2A0un", { + "type": "parameter", "id": "GqDH_PSDF_8QXqPW2A0un", "scopeInstanceId": "uKWGyE9JY3cPwY-xI9vk6", - "name": "system", - "type": "parameter" + "name": "system" } ], [ "UiZtSC5BK8Ub-Ow7-XOdW", { + "type": "parameter", "id": "UiZtSC5BK8Ub-Ow7-XOdW", "scopeInstanceId": "EDEfpMPRqDejthtwkH7ws", - "name": "system", - "type": "parameter" + "name": "system" } ], [ "-CX9SNsPSue42RkfxjL8_", { + "type": "parameter", "id": "-CX9SNsPSue42RkfxjL8_", "scopeInstanceId": "a-4nDFkaWy4px1fn38XWJ", - "name": "system", - "type": "parameter" + "name": "system" } ], [ "Q6XrK4-ZZUdXsocdiWqVk", { + "type": "parameter", "id": "Q6XrK4-ZZUdXsocdiWqVk", "scopeInstanceId": "O-ljaGZQ0iRNTlEshMkgE", - "name": "system", - "type": "parameter" + "name": "system" } ], [ "keP0A66msOpEL6HbrC9OJ", { + "type": "parameter", "id": "keP0A66msOpEL6HbrC9OJ", "scopeInstanceId": "AWY2qZfpbykoiWELeJhse", - "name": "system", - "type": "parameter" + "name": "system" } ], [ "8qqAbq6TEA3ccoCagjlZL", { + "type": "parameter", "id": "8qqAbq6TEA3ccoCagjlZL", "scopeInstanceId": "L0ZXd5F9xk9Rsl9ORzIkJ", - "name": "system", - "type": "parameter" + "name": "system" + } + ], + [ + "8tTIagkgzmdy4T4qzKXqd", + { + "type": "parameter", + "id": "8tTIagkgzmdy4T4qzKXqd", + "scopeInstanceId": "MTUwXTcUJcWmr9iGvdcoH", + "name": "system" + } + ], + [ + "LYqm_7uq8W4T7BKky9zcj", + { + "type": "resource", + "id": "LYqm_7uq8W4T7BKky9zcj", + "scopeInstanceId": "MTUwXTcUJcWmr9iGvdcoH", + "name": "jsonResourceVariable", + "resourceId": "fjMzCru8O2U31xY2P1Ovr" + } + ], + [ + "KqMdUAQ4YmObkN1ofVzeO", + { + "type": "variable", + "id": "KqMdUAQ4YmObkN1ofVzeO", + "scopeInstanceId": "MTUwXTcUJcWmr9iGvdcoH", + "name": "jsonVar", + "value": { + "type": "json", + "value": { + "hello": "world" + } + } } ] ], @@ -2299,6 +2398,16 @@ "headers": [], "body": "" } + ], + [ + "fjMzCru8O2U31xY2P1Ovr", + { + "id": "fjMzCru8O2U31xY2P1Ovr", + "name": "jsonResourceVariable", + "method": "get", + "url": "\"https://httpbin.org/get?hello=world\"", + "headers": [] + } ] ], "instances": [ @@ -3341,9 +3450,52 @@ } ] } + ], + [ + "MTUwXTcUJcWmr9iGvdcoH", + { + "type": "instance", + "id": "MTUwXTcUJcWmr9iGvdcoH", + "component": "Body", + "children": [ + { + "type": "id", + "value": "DiiJ6c5jxLGydyTZTCdg0" + }, + { + "type": "id", + "value": "sHn4Mh7DJ5X0RiETB-zVc" + } + ] + } + ], + [ + "DiiJ6c5jxLGydyTZTCdg0", + { + "type": "instance", + "id": "DiiJ6c5jxLGydyTZTCdg0", + "component": "Heading", + "label": "worl", + "children": [ + { + "type": "expression", + "value": "`${$ws$dataSource$LYqm_7uq8W4T7BKky9zcj.data.args}`" + } + ] + } + ], + [ + "sHn4Mh7DJ5X0RiETB-zVc", + { + "type": "instance", + "id": "sHn4Mh7DJ5X0RiETB-zVc", + "component": "HtmlEmbed", + "children": [] + } ] ], "deployment": { + "destination": "saas", "domains": [], "projectDomain": "webstudio-fixture-project-a-0su3o" }, @@ -3353,6 +3505,8 @@ "id": "7Db64ZXgYiRqKSQNR-qTQ", "name": "Home", "title": "\"The Ultimate Cat Protection Zone\"", + "rootInstanceId": "On9cvWCxr5rdZtY9O1Bv0", + "systemDataSourceId": "u9wtUkt2-E0RDaljl_I6E", "meta": { "description": "\"Dive into the world of felines and discover why some whiskers are best left untouched. From intriguing cat behaviors to protective measures, \\nKittyGuardedZone is your go-to hub for all things 'hands-off' in the cat realm.\"", "socialImageAssetId": "cd939c56-bcdd-4e64-bd9c-567a9bccd3da", @@ -3363,15 +3517,15 @@ } ] }, - "rootInstanceId": "On9cvWCxr5rdZtY9O1Bv0", - "path": "", - "systemDataSourceId": "u9wtUkt2-E0RDaljl_I6E" + "path": "" }, "pages": [ { "id": "7Db64ZXgYiRqKSQNR-qTQ", "name": "Home", "title": "\"The Ultimate Cat Protection Zone\"", + "rootInstanceId": "On9cvWCxr5rdZtY9O1Bv0", + "systemDataSourceId": "u9wtUkt2-E0RDaljl_I6E", "meta": { "description": "\"Dive into the world of felines and discover why some whiskers are best left untouched. From intriguing cat behaviors to protective measures, \\nKittyGuardedZone is your go-to hub for all things 'hands-off' in the cat realm.\"", "socialImageAssetId": "cd939c56-bcdd-4e64-bd9c-567a9bccd3da", @@ -3382,60 +3536,60 @@ } ] }, - "rootInstanceId": "On9cvWCxr5rdZtY9O1Bv0", - "path": "", - "systemDataSourceId": "u9wtUkt2-E0RDaljl_I6E" + "path": "" }, { "id": "xfvB4UThQXmQ_OubPYrkg", "name": "radix excluded from the search", "title": "\"Radix Revelations: Unraveling the Feline Mystique\"", + "rootInstanceId": "uKWGyE9JY3cPwY-xI9vk6", + "systemDataSourceId": "GqDH_PSDF_8QXqPW2A0un", "meta": { "description": "\"Delve deep into the radix roots of feline behaviors. At KittyNoTouchy, we dissect the core essence, or 'radix', of what makes cats the enigmatic creatures they are. Join us as we explore the radix of their instincts, habits, and quirks.\"", "excludePageFromSearch": "true", "socialImageAssetId": "88d5e2ff-b8f2-4899-aaf8-dde4ade6da10" }, - "rootInstanceId": "uKWGyE9JY3cPwY-xI9vk6", - "path": "/radix", - "systemDataSourceId": "GqDH_PSDF_8QXqPW2A0un" + "path": "/radix" }, { "id": "szYLvBduHPmbtqQKCDY0b", "name": "RouteWithSymbols", "title": "\"RouteWithSymbols\"", + "rootInstanceId": "EDEfpMPRqDejthtwkH7ws", + "systemDataSourceId": "UiZtSC5BK8Ub-Ow7-XOdW", "meta": { "description": "\"\"" }, - "rootInstanceId": "EDEfpMPRqDejthtwkH7ws", - "path": "/_route_with_symbols_", - "systemDataSourceId": "UiZtSC5BK8Ub-Ow7-XOdW" + "path": "/_route_with_symbols_" }, { "id": "U1tRJl2ERr8_OFe0g9cN_", "name": "form", "title": "\"form\"", + "rootInstanceId": "a-4nDFkaWy4px1fn38XWJ", + "systemDataSourceId": "-CX9SNsPSue42RkfxjL8_", "meta": { "description": "\"\"" }, - "rootInstanceId": "a-4nDFkaWy4px1fn38XWJ", - "path": "/form", - "systemDataSourceId": "-CX9SNsPSue42RkfxjL8_" + "path": "/form" }, { "id": "-J9I4Oo6mONfQlf_3-OqG", "name": "heading-with-id", "title": "\"heading-with-id\"", + "rootInstanceId": "O-ljaGZQ0iRNTlEshMkgE", + "systemDataSourceId": "Q6XrK4-ZZUdXsocdiWqVk", "meta": { "description": "\"\"" }, - "rootInstanceId": "O-ljaGZQ0iRNTlEshMkgE", - "path": "/heading-with-id", - "systemDataSourceId": "Q6XrK4-ZZUdXsocdiWqVk" + "path": "/heading-with-id" }, { "id": "TS39WeBd0Qr3QgbSlzdf2", "name": "resources", "title": "\"resources\"", + "rootInstanceId": "AWY2qZfpbykoiWELeJhse", + "systemDataSourceId": "keP0A66msOpEL6HbrC9OJ", "meta": { "description": "\"\"", "excludePageFromSearch": "false", @@ -3447,14 +3601,14 @@ } ] }, - "rootInstanceId": "AWY2qZfpbykoiWELeJhse", - "path": "/resources", - "systemDataSourceId": "keP0A66msOpEL6HbrC9OJ" + "path": "/resources" }, { "id": "Pnz-BxUm6XmvFygk_XdEy", "name": "Nested Page", "title": "\"Nested Page\"", + "rootInstanceId": "L0ZXd5F9xk9Rsl9ORzIkJ", + "systemDataSourceId": "8qqAbq6TEA3ccoCagjlZL", "meta": { "description": "\"\"", "excludePageFromSearch": "false", @@ -3466,9 +3620,28 @@ } ] }, - "rootInstanceId": "L0ZXd5F9xk9Rsl9ORzIkJ", - "path": "/nested-page", - "systemDataSourceId": "8qqAbq6TEA3ccoCagjlZL" + "path": "/nested-page" + }, + { + "id": "9xlwLSHxuk8HmS3-EEGcf", + "name": "expressions", + "title": "\"Untitled\"", + "rootInstanceId": "MTUwXTcUJcWmr9iGvdcoH", + "systemDataSourceId": "8tTIagkgzmdy4T4qzKXqd", + "meta": { + "description": "\"\"", + "excludePageFromSearch": "true", + "language": "\"\"", + "socialImageUrl": "\"\"", + "status": "200", + "redirect": "\"\"", + "documentType": "html", + "custom": [] + }, + "marketplace": { + "include": false + }, + "path": "/expressions" } ], "assets": [ diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/$resources.sitemap.xml.ts b/fixtures/webstudio-remix-vercel/app/__generated__/$resources.sitemap.xml.ts index e0e783ef5f..9b9443cc7d 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/$resources.sitemap.xml.ts +++ b/fixtures/webstudio-remix-vercel/app/__generated__/$resources.sitemap.xml.ts @@ -1,26 +1,26 @@ export const sitemap = [ { path: "/", - lastModified: "2024-05-30", + lastModified: "2024-08-07", }, { path: "/_route_with_symbols_", - lastModified: "2024-05-30", + lastModified: "2024-08-07", }, { path: "/form", - lastModified: "2024-05-30", + lastModified: "2024-08-07", }, { path: "/heading-with-id", - lastModified: "2024-05-30", + lastModified: "2024-08-07", }, { path: "/resources", - lastModified: "2024-05-30", + lastModified: "2024-08-07", }, { path: "/nested/nested-page", - lastModified: "2024-05-30", + lastModified: "2024-08-07", }, ]; diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/[_route_with_symbols_]._index.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/[_route_with_symbols_]._index.tsx index fe8dffdc05..e6a7b6696f 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/[_route_with_symbols_]._index.tsx +++ b/fixtures/webstudio-remix-vercel/app/__generated__/[_route_with_symbols_]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body } from "@webstudio-is/sdk-components-react-remix"; import { Image as Image } from "@webstudio-is/sdk-components-react"; diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/[expressions]._index.server.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/[expressions]._index.server.tsx new file mode 100644 index 0000000000..de557a6826 --- /dev/null +++ b/fixtures/webstudio-remix-vercel/app/__generated__/[expressions]._index.server.tsx @@ -0,0 +1,48 @@ +/* eslint-disable */ +/* This is a auto generated file for building the project */ + +import type { PageMeta } from "@webstudio-is/sdk"; +import type { System, ResourceRequest } from "@webstudio-is/sdk"; +export const getResources = (_props: { system: System }) => { + const jsonResourceVariable_1: ResourceRequest = { + id: "fjMzCru8O2U31xY2P1Ovr", + name: "jsonResourceVariable", + url: "https://httpbin.org/get?hello=world", + method: "get", + headers: [], + }; + const _data = new Map([ + ["jsonResourceVariable_1", jsonResourceVariable_1], + ]); + const _action = new Map([]); + return { data: _data, action: _action }; +}; + +export const getPageMeta = ({ + system, + resources, +}: { + system: System; + resources: Record; +}): PageMeta => { + return { + title: "Untitled", + description: "", + excludePageFromSearch: true, + language: "", + socialImageAssetName: undefined, + socialImageUrl: "", + status: 200, + redirect: "", + custom: [], + }; +}; + +type Params = Record; +export const getRemixParams = ({ ...params }: Params): Params => { + return params; +}; + +export const projectId = "cddc1d44-af37-4cb6-a430-d300cf6f932d"; + +export const contactEmail = "hello@webstudio.is"; diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/[expressions]._index.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/[expressions]._index.tsx new file mode 100644 index 0000000000..3801a35a0e --- /dev/null +++ b/fixtures/webstudio-remix-vercel/app/__generated__/[expressions]._index.tsx @@ -0,0 +1,54 @@ +/* eslint-disable */ +/* This is a auto generated file for building the project */ + +import { Fragment, useState } from "react"; +import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; +import { Body as Body } from "@webstudio-is/sdk-components-react-remix"; +import { + Heading as Heading, + HtmlEmbed as HtmlEmbed, +} from "@webstudio-is/sdk-components-react"; + +export const siteName = "KittyGuardedZone"; + +export const favIconAsset: ImageAsset | undefined = { + id: "88d5e2ff-b8f2-4899-aaf8-dde4ade6da10", + name: "DALL_E_2023-10-30_12.39.46_-_Photo_logo_with_a_bold_cat_silhouette_centered_on_a_contrasting_background_designed_for_clarity_at_small_32x32_favicon_resolution_00h6cEA8u2pJRvVJv7hRe.png", + description: null, + projectId: "cddc1d44-af37-4cb6-a430-d300cf6f932d", + size: 268326, + type: "image", + format: "png", + createdAt: "2023-10-30T13:51:08.416", + meta: { width: 790, height: 786 }, +}; + +// Font assets on current page (can be preloaded) +export const pageFontAssets: FontAsset[] = []; + +export const pageBackgroundImageAssets: ImageAsset[] = []; + +const Page = ({}: { system: any }) => { + let jsonResourceVariable = useResource("jsonResourceVariable_1"); + let [jsonVar, set$jsonVar] = useVariableState({ hello: "world" }); + return ( + + + {`${jsonResourceVariable?.data?.args}`} + + +const a = ${jsonResourceVariable?.data?.args} + +const b = ${jsonVar} + +console.log(a, b); +`} + className="w-html-embed" + /> + + ); +}; + +export { Page }; diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx index 13806ada82..a597124dc4 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx +++ b/fixtures/webstudio-remix-vercel/app/__generated__/[form]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Form as Form, @@ -36,8 +36,8 @@ export const pageFontAssets: FontAsset[] = []; export const pageBackgroundImageAssets: ImageAsset[] = []; const Page = ({}: { system: any }) => { - let [formState, set$formState] = useState("initial"); - let [formState_1, set$formState_1] = useState("initial"); + let [formState, set$formState] = useVariableState("initial"); + let [formState_1, set$formState_1] = useVariableState("initial"); return (
{ - let [accordionValue, set$accordionValue] = useState("0"); + let [accordionValue, set$accordionValue] = useVariableState("0"); return ( { - + {"Yes. It adheres to the WAI-ARIA design pattern."} @@ -82,7 +82,7 @@ const Page = ({}: { system: any }) => { - + { "Yes. It comes with default styles that matches the other components' aesthetic." } @@ -102,7 +102,7 @@ const Page = ({}: { system: any }) => { - + { "Yes. It's animated by default, but you can disable it if you prefer." } diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/[resources]._index.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/[resources]._index.tsx index 5db388fd57..504cf3c5c7 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/[resources]._index.tsx +++ b/fixtures/webstudio-remix-vercel/app/__generated__/[resources]._index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body } from "@webstudio-is/sdk-components-react-remix"; import { Box as Box, diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/_index.tsx b/fixtures/webstudio-remix-vercel/app/__generated__/_index.tsx index 62cb4a0271..151eca28c3 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/_index.tsx +++ b/fixtures/webstudio-remix-vercel/app/__generated__/_index.tsx @@ -3,7 +3,7 @@ import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; -import { useResource } from "@webstudio-is/react-sdk/runtime"; +import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; import { Body as Body, Link as Link, diff --git a/fixtures/webstudio-remix-vercel/app/__generated__/index.css b/fixtures/webstudio-remix-vercel/app/__generated__/index.css index 3d014123cc..66c976ef55 100644 --- a/fixtures/webstudio-remix-vercel/app/__generated__/index.css +++ b/fixtures/webstudio-remix-vercel/app/__generated__/index.css @@ -368,15 +368,18 @@ html { .c1vri55v { transition-duration: 200ms; } - .c1mxwmum { - overflow: hidden; - } .c1j0j9ep { font-size: 0.875rem; } .c1gl2i2 { line-height: 1.25rem; } + .c1xcxvc0 { + overflow-x: hidden; + } + .c12ct49k { + overflow-y: hidden; + } .ch2exr5 { display: block; } diff --git a/fixtures/webstudio-remix-vercel/app/routes/[expressions]._index.tsx b/fixtures/webstudio-remix-vercel/app/routes/[expressions]._index.tsx new file mode 100644 index 0000000000..b6ed3fb8ce --- /dev/null +++ b/fixtures/webstudio-remix-vercel/app/routes/[expressions]._index.tsx @@ -0,0 +1,342 @@ +import { + type ServerRuntimeMetaFunction as MetaFunction, + type LinksFunction, + type LinkDescriptor, + type ActionFunctionArgs, + type LoaderFunctionArgs, + type HeadersFunction, + json, + redirect, +} from "@remix-run/server-runtime"; +import { useLoaderData } from "@remix-run/react"; +import { + isLocalResource, + loadResource, + loadResources, + formIdFieldName, + formBotFieldName, +} from "@webstudio-is/sdk"; +import { ReactSdkContext } from "@webstudio-is/react-sdk/runtime"; +import { + Page, + siteName, + favIconAsset, + pageFontAssets, + pageBackgroundImageAssets, +} from "../__generated__/[expressions]._index"; +import { + getResources, + getPageMeta, + getRemixParams, + projectId, + contactEmail, +} from "../__generated__/[expressions]._index.server"; +import { assetBaseUrl, imageBaseUrl, imageLoader } from "../constants.mjs"; +import css from "../__generated__/index.css?url"; +import { sitemap } from "../__generated__/$resources.sitemap.xml"; + +const customFetch: typeof fetch = (input, init) => { + if (typeof input !== "string") { + return fetch(input, init); + } + + if (isLocalResource(input, "sitemap.xml")) { + // @todo: dynamic import sitemap ??? + const response = new Response(JSON.stringify(sitemap)); + response.headers.set("content-type", "application/json; charset=utf-8"); + return Promise.resolve(response); + } + + return fetch(input, init); +}; + +export const loader = async (arg: LoaderFunctionArgs) => { + const url = new URL(arg.request.url); + const host = + arg.request.headers.get("x-forwarded-host") || + arg.request.headers.get("host") || + ""; + url.host = host; + url.protocol = "https"; + + const params = getRemixParams(arg.params); + const system = { + params, + search: Object.fromEntries(url.searchParams), + origin: url.origin, + }; + + const resources = await loadResources( + customFetch, + getResources({ system }).data + ); + const pageMeta = getPageMeta({ system, resources }); + + if (pageMeta.redirect) { + const status = + pageMeta.status === 301 || pageMeta.status === 302 + ? pageMeta.status + : 302; + return redirect(pageMeta.redirect, status); + } + + // typecheck + arg.context.EXCLUDE_FROM_SEARCH satisfies boolean; + + if (arg.context.EXCLUDE_FROM_SEARCH) { + pageMeta.excludePageFromSearch = arg.context.EXCLUDE_FROM_SEARCH; + } + + return json( + { + host, + url: url.href, + system, + resources, + pageMeta, + }, + // No way for current information to change, so add cache for 10 minutes + // In case of CRM Data, this should be set to 0 + { + status: pageMeta.status, + headers: { + "Cache-Control": "public, max-age=600", + }, + } + ); +}; + +export const headers: HeadersFunction = () => { + return { + "Cache-Control": "public, max-age=0, must-revalidate", + }; +}; + +export const meta: MetaFunction = ({ data }) => { + const metas: ReturnType = []; + if (data === undefined) { + return metas; + } + const { pageMeta } = data; + + if (data.url) { + metas.push({ + property: "og:url", + content: data.url, + }); + } + + if (pageMeta.title) { + metas.push({ title: pageMeta.title }); + + metas.push({ + property: "og:title", + content: pageMeta.title, + }); + } + + metas.push({ property: "og:type", content: "website" }); + + const origin = `https://${data.host}`; + + if (siteName) { + metas.push({ + property: "og:site_name", + content: siteName, + }); + metas.push({ + "script:ld+json": { + "@context": "https://schema.org", + "@type": "WebSite", + name: siteName, + url: origin, + }, + }); + } + + if (pageMeta.excludePageFromSearch) { + metas.push({ + name: "robots", + content: "noindex, nofollow", + }); + } + + if (pageMeta.description) { + metas.push({ + name: "description", + content: pageMeta.description, + }); + metas.push({ + property: "og:description", + content: pageMeta.description, + }); + } + + if (pageMeta.socialImageAssetName) { + metas.push({ + property: "og:image", + content: `https://${data.host}${imageLoader({ + src: pageMeta.socialImageAssetName, + // Do not transform social image (not enough information do we need to do this) + format: "raw", + })}`, + }); + } else if (pageMeta.socialImageUrl) { + metas.push({ + property: "og:image", + content: pageMeta.socialImageUrl, + }); + } + + metas.push(...pageMeta.custom); + + return metas; +}; + +export const links: LinksFunction = () => { + const result: LinkDescriptor[] = []; + + result.push({ + rel: "stylesheet", + href: css, + }); + + if (favIconAsset) { + result.push({ + rel: "icon", + href: imageLoader({ + src: favIconAsset.name, + // width,height must be multiple of 48 https://developers.google.com/search/docs/appearance/favicon-in-search + width: 144, + height: 144, + fit: "pad", + quality: 100, + format: "auto", + }), + type: undefined, + }); + } + + for (const asset of pageFontAssets) { + result.push({ + rel: "preload", + href: `${assetBaseUrl}${asset.name}`, + as: "font", + crossOrigin: "anonymous", + }); + } + + for (const backgroundImageAsset of pageBackgroundImageAssets) { + result.push({ + rel: "preload", + href: `${assetBaseUrl}${backgroundImageAsset.name}`, + as: "image", + }); + } + + return result; +}; + +const getRequestHost = (request: Request): string => + request.headers.get("x-forwarded-host") || request.headers.get("host") || ""; + +export const action = async ({ + request, + context, +}: ActionFunctionArgs): Promise< + { success: true } | { success: false; errors: string[] } +> => { + try { + const url = new URL(request.url); + url.host = getRequestHost(request); + + const formData = await request.formData(); + + const system = { + params: {}, + search: {}, + origin: url.origin, + }; + + const resourceName = formData.get(formIdFieldName); + let resource = + typeof resourceName === "string" + ? getResources({ system }).action.get(resourceName) + : undefined; + + const formBotValue = formData.get(formBotFieldName); + + if (formBotValue == null || typeof formBotValue !== "string") { + throw new Error("Form bot field not found"); + } + + const submitTime = parseInt(formBotValue, 16); + // Assumes that the difference between the server time and the form submission time, + // including any client-server time drift, is within a 5-minute range. + // Note: submitTime might be NaN because formBotValue can be any string used for logging purposes. + // Example: `formBotValue: jsdom`, or `formBotValue: headless-env` + if ( + Number.isNaN(submitTime) || + Math.abs(Date.now() - submitTime) > 1000 * 60 * 5 + ) { + throw new Error(`Form bot value invalid ${formBotValue}`); + } + + formData.delete(formIdFieldName); + formData.delete(formBotFieldName); + + if (resource) { + resource.headers.push({ + name: "Content-Type", + value: "application/json", + }); + resource.body = Object.fromEntries(formData); + } else { + if (contactEmail === undefined) { + throw new Error("Contact email not found"); + } + + resource = context.getDefaultActionResource?.({ + url, + projectId, + contactEmail, + formData, + }); + } + + if (resource === undefined) { + throw Error("Resource not found"); + } + const { ok, statusText } = await loadResource(fetch, resource); + if (ok) { + return { success: true }; + } + return { success: false, errors: [statusText] }; + } catch (error) { + console.error(error); + + return { + success: false, + errors: [error instanceof Error ? error.message : "Unknown error"], + }; + } +}; + +const Outlet = () => { + const { system, resources, url } = useLoaderData(); + return ( + + {/* Use the URL as the key to force scripts in HTML Embed to reload on dynamic pages */} + + + ); +}; + +export default Outlet; diff --git a/fixtures/webstudio-remix-vercel/package.json b/fixtures/webstudio-remix-vercel/package.json index 5cef785fa3..87ab1da2aa 100644 --- a/fixtures/webstudio-remix-vercel/package.json +++ b/fixtures/webstudio-remix-vercel/package.json @@ -6,7 +6,7 @@ "typecheck": "tsc", "cli": "NODE_OPTIONS='--conditions=webstudio --import=tsx' webstudio", "fixtures:link": "pnpm cli link --link ${BUILDER_URL:-https://main.development.webstudio.is}'/builder/cddc1d44-af37-4cb6-a430-d300cf6f932d?authToken=1cdc6026-dd5b-4624-b89b-9bd45e9bcc3d'", - "fixtures:sync": "pnpm cli sync --buildId 3dce3f4f-3c20-46e5-a885-d1b543db6237 && pnpm prettier --write ./.webstudio/", + "fixtures:sync": "pnpm cli sync --buildId 0f80ffe8-7059-4577-8ef3-232f3aded028 && pnpm prettier --write ./.webstudio/", "fixtures:build": "pnpm cli build --template vercel --template internal --preview && pnpm prettier --write ./app/ ./package.json ./tsconfig.json" }, "private": true, diff --git a/packages/cli/src/prebuild.ts b/packages/cli/src/prebuild.ts index 476a76edbf..4d4facffb5 100644 --- a/packages/cli/src/prebuild.ts +++ b/packages/cli/src/prebuild.ts @@ -496,6 +496,7 @@ export const prebuild = async (options: { "useState", "Fragment", "useResource", + "useVariableState", "Page", "_props", ]); @@ -615,7 +616,7 @@ export const prebuild = async (options: { import { Fragment, useState } from "react"; import type { FontAsset, ImageAsset } from "@webstudio-is/sdk"; - import { useResource } from "@webstudio-is/react-sdk/runtime"; + import { useResource, useVariableState } from "@webstudio-is/react-sdk/runtime"; ${componentImports} export const siteName = ${JSON.stringify(projectMeta?.siteName)}; diff --git a/packages/react-sdk/src/component-generator.test.tsx b/packages/react-sdk/src/component-generator.test.tsx index 7d77831d03..33e77340a7 100644 --- a/packages/react-sdk/src/component-generator.test.tsx +++ b/packages/react-sdk/src/component-generator.test.tsx @@ -472,7 +472,7 @@ test("generate component with variables and actions", () => { ).toEqual( clear(` const Page = () => { - let [variableName, set$variableName] = useState("initial") + let [variableName, set$variableName] = useVariableState("initial") return { ).toEqual( clear(` const Page = () => { - let [data, set$data] = useState(["apple","orange","mango"]) + let [data, set$data] = useVariableState(["apple","orange","mango"]) return {data?.map((element: any, index: number) => @@ -637,7 +637,7 @@ test("generate resources loading", () => { ).toEqual( clear(` const Page = () => { - let [data, set$data] = useState("data") + let [data, set$data] = useVariableState("data") let data_1 = useResource("data_2") return { }) ).toMatchInlineSnapshot(` "const Page = ({ }: { system: any; }) => { -let [UsedVariableName, set$UsedVariableName] = useState("initial") +let [UsedVariableName, set$UsedVariableName] = useVariableState("initial") return } @@ -771,21 +771,21 @@ test("generate conditional collection", () => { ), }) ).toMatchInlineSnapshot(` - "const Page = () => { - let [conditionName, set$conditionName] = useState(false) - return - {(conditionName) && - <> - {[]?.map((collectionItemName: any, index: number) => - - - )} - - } - - } - " - `); +"const Page = () => { +let [conditionName, set$conditionName] = useVariableState(false) +return +{(conditionName) && +<> +{[]?.map((collectionItemName: any, index: number) => + + +)} + +} + +} +" +`); }); test("generate resource prop", () => { diff --git a/packages/react-sdk/src/component-generator.ts b/packages/react-sdk/src/component-generator.ts index 4a10bf4248..14914a1206 100644 --- a/packages/react-sdk/src/component-generator.ts +++ b/packages/react-sdk/src/component-generator.ts @@ -424,7 +424,7 @@ export const generateWebstudioComponent = ({ ); const initialValue = dataSource.value.value; const initialValueString = JSON.stringify(initialValue); - generatedDataSources += `let [${valueName}, ${setterName}] = useState(${initialValueString})\n`; + generatedDataSources += `let [${valueName}, ${setterName}] = useVariableState(${initialValueString})\n`; } if (dataSource.type === "resource") { const valueName = scope.getName(dataSource.id, dataSource.name); diff --git a/packages/react-sdk/src/context.tsx b/packages/react-sdk/src/context.tsx index 31f880560c..a8eb879335 100644 --- a/packages/react-sdk/src/context.tsx +++ b/packages/react-sdk/src/context.tsx @@ -1,5 +1,6 @@ -import { createContext, useContext } from "react"; +import { createContext, useContext, useMemo } from "react"; import type { ImageLoader } from "@webstudio-is/image"; +import { createJsonStringifyProxy, isPlainObject } from "@webstudio-is/sdk"; export type Params = { /** @@ -49,5 +50,13 @@ export const ReactSdkContext = createContext< export const useResource = (name: string) => { const { resources } = useContext(ReactSdkContext); - return resources[name]; + const resource = resources[name]; + + const resourceMemozied = useMemo( + () => + isPlainObject(resource) ? createJsonStringifyProxy(resource) : resource, + [resource] + ); + + return resourceMemozied; }; diff --git a/packages/react-sdk/src/runtime.ts b/packages/react-sdk/src/runtime.ts index 50cdda4b6c..5ddc040224 100644 --- a/packages/react-sdk/src/runtime.ts +++ b/packages/react-sdk/src/runtime.ts @@ -1,5 +1,6 @@ export * from "./context"; export * from "./hook"; +export * from "./variable-state"; export const getIndexWithinAncestorFromComponentProps = ( props: Record diff --git a/packages/react-sdk/src/variable-state.tsx b/packages/react-sdk/src/variable-state.tsx new file mode 100644 index 0000000000..13421aee85 --- /dev/null +++ b/packages/react-sdk/src/variable-state.tsx @@ -0,0 +1,15 @@ +import { createJsonStringifyProxy, isPlainObject } from "@webstudio-is/sdk"; +import { useState, useMemo, type Dispatch, type SetStateAction } from "react"; + +export const useVariableState = ( + initialState: S | (() => S) +): [S, Dispatch>] => { + const [state, setState] = useState(initialState); + + const value = useMemo( + () => (isPlainObject(state) ? createJsonStringifyProxy(state) : state), + [state] + ); + + return [value, setState]; +}; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 2cad79e292..1566f02080 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -20,3 +20,4 @@ export * from "./resources-generator"; export * from "./page-meta-generator"; export * from "./url-pattern"; export * from "./form-fields"; +export * from "./to-string"; diff --git a/packages/sdk/src/to-string.ts b/packages/sdk/src/to-string.ts new file mode 100644 index 0000000000..ddcf869830 --- /dev/null +++ b/packages/sdk/src/to-string.ts @@ -0,0 +1,27 @@ +export const createJsonStringifyProxy = (target: T): T => { + return new Proxy(target, { + get(target, prop, receiver) { + if (prop === "toString") { + return function () { + return JSON.stringify(target); + }; + } + + const value = Reflect.get(target, prop, receiver); + + if (typeof value === "object" && value !== null) { + return createJsonStringifyProxy(value); + } + + return value; + }, + }); +}; + +export const isPlainObject = (value: unknown): value is object => { + return ( + Object.prototype.toString.call(value) === "[object Object]" && + (Object.getPrototypeOf(value) === null || + Object.getPrototypeOf(value) === Object.prototype) + ); +};