mirror of
https://github.com/webstudio-is/webstudio.git
synced 2025-03-14 09:57:02 +00:00
## Description 1. What is this PR about (link the issue and add a short description) ## Steps for reproduction 1. click button 2. expect xyz ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
296 lines
7.0 KiB
TypeScript
296 lines
7.0 KiB
TypeScript
import {
|
|
type MetaFunction,
|
|
type LinksFunction,
|
|
type LinkDescriptor,
|
|
type ActionFunctionArgs,
|
|
type LoaderFunctionArgs,
|
|
type HeadersFunction,
|
|
data,
|
|
redirect,
|
|
useLoaderData,
|
|
} from "react-router";
|
|
import {
|
|
isLocalResource,
|
|
loadResource,
|
|
loadResources,
|
|
formIdFieldName,
|
|
formBotFieldName,
|
|
} from "@webstudio-is/sdk/runtime";
|
|
import {
|
|
ReactSdkContext,
|
|
PageSettingsMeta,
|
|
PageSettingsTitle,
|
|
} from "@webstudio-is/react-sdk/runtime";
|
|
import {
|
|
Page,
|
|
siteName,
|
|
favIconAsset,
|
|
pageFontAssets,
|
|
pageBackgroundImageAssets,
|
|
} from "../__generated__/[animations]._index";
|
|
import {
|
|
getResources,
|
|
getPageMeta,
|
|
getRemixParams,
|
|
projectId,
|
|
contactEmail,
|
|
} from "../__generated__/[animations]._index.server";
|
|
import { assetBaseUrl, 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;
|
|
throw 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 data(
|
|
{
|
|
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<typeof loader> = ({ data }) => {
|
|
const metas: ReturnType<MetaFunction> = [];
|
|
if (data === undefined) {
|
|
return metas;
|
|
}
|
|
|
|
const origin = `https://${data.host}`;
|
|
|
|
if (siteName) {
|
|
metas.push({
|
|
"script:ld+json": {
|
|
"@context": "https://schema.org",
|
|
"@type": "WebSite",
|
|
name: siteName,
|
|
url: origin,
|
|
},
|
|
});
|
|
}
|
|
|
|
return metas;
|
|
};
|
|
|
|
export const links: LinksFunction = () => {
|
|
const result: LinkDescriptor[] = [];
|
|
|
|
result.push({
|
|
rel: "stylesheet",
|
|
href: css,
|
|
});
|
|
|
|
if (favIconAsset) {
|
|
result.push({
|
|
rel: "icon",
|
|
href: imageLoader({
|
|
src: `${assetBaseUrl}${favIconAsset}`,
|
|
// 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}`,
|
|
as: "font",
|
|
crossOrigin: "anonymous",
|
|
});
|
|
}
|
|
|
|
for (const backgroundImageAsset of pageBackgroundImageAssets) {
|
|
result.push({
|
|
rel: "preload",
|
|
href: `${assetBaseUrl}${backgroundImageAsset}`,
|
|
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, pageMeta, host } =
|
|
useLoaderData<typeof loader>();
|
|
return (
|
|
<ReactSdkContext.Provider
|
|
value={{
|
|
imageLoader,
|
|
assetBaseUrl,
|
|
resources,
|
|
}}
|
|
>
|
|
{/* Use the URL as the key to force scripts in HTML Embed to reload on dynamic pages */}
|
|
<Page key={url} system={system} />
|
|
<PageSettingsMeta
|
|
url={url}
|
|
pageMeta={pageMeta}
|
|
host={host}
|
|
siteName={siteName}
|
|
imageLoader={imageLoader}
|
|
/>
|
|
<PageSettingsTitle>{pageMeta.title}</PageSettingsTitle>
|
|
</ReactSdkContext.Provider>
|
|
);
|
|
};
|
|
|
|
export default Outlet;
|