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, cachedFetch, } from "@webstudio-is/sdk/runtime"; import { ReactSdkContext, PageSettingsMeta, PageSettingsTitle, } from "@webstudio-is/react-sdk/runtime"; import { projectId, Page, siteName, favIconAsset, pageFontAssets, pageBackgroundImageAssets, breakpoints, } from "../__generated__/[style-guide]._index"; import { getResources, getPageMeta, getRemixParams, contactEmail, } from "../__generated__/[style-guide]._index.server"; import * as constants 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 cachedFetch(projectId, 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 cachedFetch(projectId, 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 = ({ data }) => { const metas: ReturnType = []; 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: constants.imageLoader({ src: `${constants.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: `${constants.assetBaseUrl}${asset}`, as: "font", crossOrigin: "anonymous", }); } for (const backgroundImageAsset of pageBackgroundImageAssets) { result.push({ rel: "preload", href: `${constants.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.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(); return ( {/* Use the URL as the key to force scripts in HTML Embed to reload on dynamic pages */} {pageMeta.title} ); }; export default Outlet;