mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-09 05:26:24 +09:00
1 parent
c883d8d1af
commit
55280b8348
16 files changed
+218
-71
No files matched your search
@@ -6,7 +6,6 @@ import {
|
||||
useAllUserProps,
|
||||
registerComponents,
|
||||
registerComponentsMeta,
|
||||
customComponents,
|
||||
customComponentsMeta,
|
||||
setParams,
|
||||
type OnChangeChildren,
|
||||
@@ -51,6 +50,7 @@ import { useSubscribeDesignerReady } from "./shared/use-designer-ready";
|
||||
import type { Asset } from "@webstudio-is/asset-uploader";
|
||||
import { useInstanceCopyPaste } from "~/shared/copy-paste";
|
||||
import { useSelectedInstance } from "./shared/nano-states";
|
||||
import { customComponents } from "./custom-components";
|
||||
|
||||
registerContainers();
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { customComponents as baseCustomComponents } from "@webstudio-is/react-sdk";
|
||||
import { wrapLinkComponent } from "./link";
|
||||
|
||||
export const customComponents = {
|
||||
...baseCustomComponents,
|
||||
Link: wrapLinkComponent(baseCustomComponents.Link),
|
||||
RichTextLink: wrapLinkComponent(baseCustomComponents.RichTextLink),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { preserveBuildParams } from "./link";
|
||||
|
||||
describe("preserveBuildParams", () => {
|
||||
test("absolute url is returned as is", () => {
|
||||
const search = "?mode=preview&projectId=123";
|
||||
expect(preserveBuildParams("https://example.com", search)).toBe(
|
||||
"https://example.com"
|
||||
);
|
||||
expect(preserveBuildParams("mailto:alice@example.com", search)).toBe(
|
||||
"mailto:alice@example.com"
|
||||
);
|
||||
});
|
||||
|
||||
test("doesn't crash on a weird value", () => {
|
||||
expect(preserveBuildParams("http://", "?projectId=123")).toBe("http://");
|
||||
});
|
||||
|
||||
test("projectId is preserved", () => {
|
||||
expect(preserveBuildParams("/foo", "?projectId=123")).toBe(
|
||||
"/foo?projectId=123"
|
||||
);
|
||||
});
|
||||
|
||||
test("mode is preserved", () => {
|
||||
expect(preserveBuildParams("/foo", "?mode=preview")).toBe(
|
||||
"/foo?mode=preview"
|
||||
);
|
||||
});
|
||||
|
||||
test("invalid mode is not preserved", () => {
|
||||
expect(preserveBuildParams("/foo", "?mode=invalid")).toBe("/foo");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import type {
|
||||
ElementRef,
|
||||
ComponentProps,
|
||||
ForwardRefExoticComponent,
|
||||
RefAttributes,
|
||||
} from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { preserveSearchBuildParams } from "~/shared/router-utils";
|
||||
|
||||
const isAbsoluteUrl = (href: string) => {
|
||||
try {
|
||||
new URL(href);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const preserveBuildParams = (href: string, sourceSearch: string) => {
|
||||
if (isAbsoluteUrl(href)) {
|
||||
return href;
|
||||
}
|
||||
|
||||
let url;
|
||||
let sourceSearchParams;
|
||||
try {
|
||||
url = new URL(
|
||||
href,
|
||||
// doesn't affect anything, just need some base to use URL
|
||||
"http://localhost"
|
||||
);
|
||||
sourceSearchParams = new URLSearchParams(sourceSearch);
|
||||
|
||||
// eslint-disable-next-line no-empty
|
||||
} catch (e) {}
|
||||
|
||||
if (url === undefined || sourceSearchParams === undefined) {
|
||||
return href;
|
||||
}
|
||||
|
||||
preserveSearchBuildParams(sourceSearchParams, url.searchParams);
|
||||
|
||||
const search = url.searchParams.toString();
|
||||
|
||||
return `${url.pathname}${search === "" ? "" : `?${search}`}`;
|
||||
};
|
||||
|
||||
type Props = Omit<ComponentProps<"a">, "href"> & { href?: string };
|
||||
|
||||
type Ref = ElementRef<"a">;
|
||||
|
||||
export const wrapLinkComponent = (
|
||||
BaseLink: ForwardRefExoticComponent<Props & RefAttributes<Ref>>
|
||||
) => {
|
||||
const Component = forwardRef<Ref, Props>(({ href = "", ...props }, ref) => {
|
||||
const { search } = useLocation();
|
||||
return (
|
||||
<BaseLink {...props} href={preserveBuildParams(href, search)} ref={ref} />
|
||||
);
|
||||
});
|
||||
|
||||
Component.displayName = BaseLink.displayName;
|
||||
|
||||
return Component;
|
||||
};
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { db } from "@webstudio-is/project/server";
|
||||
import type { DynamicLinksFunction } from "remix-utils";
|
||||
import type { CanvasData } from "@webstudio-is/project";
|
||||
import { customComponents } from "~/canvas/custom-components";
|
||||
|
||||
type Data = CanvasData & { env: Env; mode: BuildMode };
|
||||
|
||||
@@ -51,7 +52,7 @@ export const loader: LoaderFunction = async ({ request }): Promise<Data> => {
|
||||
throw redirect(dashboardPath());
|
||||
}
|
||||
|
||||
const { mode, pathname } = buildParams;
|
||||
const { mode } = buildParams;
|
||||
|
||||
const project = await db.project.loadByParams(buildParams);
|
||||
|
||||
@@ -62,7 +63,7 @@ export const loader: LoaderFunction = async ({ request }): Promise<Data> => {
|
||||
const canvasData = await loadCanvasData(
|
||||
project,
|
||||
mode === "published" ? "prod" : "dev",
|
||||
pathname
|
||||
"pageId" in buildParams ? buildParams.pageId : buildParams.pagePath
|
||||
);
|
||||
|
||||
if (canvasData === undefined) {
|
||||
@@ -90,7 +91,7 @@ const Content = () => {
|
||||
const Outlet =
|
||||
data.mode === "edit"
|
||||
? () => <Canvas data={data} />
|
||||
: () => <InstanceRoot data={data} />;
|
||||
: () => <InstanceRoot data={data} customComponents={customComponents} />;
|
||||
|
||||
// @todo This is non-standard for Remix, is there a better way?
|
||||
// Maybe there is a way to tell remix to use the right outlet somehow and avoid passing it?
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { ActionFunction, json } from "@remix-run/node";
|
||||
|
||||
import { BuildParams, getBuildParams } from "~/shared/router-utils";
|
||||
import { getBuildParams } from "~/shared/router-utils";
|
||||
import env from "~/env.server";
|
||||
import { sentryException } from "~/shared/sentry";
|
||||
import { generateCssText } from "~/shared/css-utils";
|
||||
|
||||
export const loader: ActionFunction = async ({ request }) => {
|
||||
try {
|
||||
const buildParams: BuildParams | undefined = getBuildParams(request);
|
||||
const buildParams = getBuildParams(request);
|
||||
|
||||
if (buildParams === undefined) {
|
||||
throw json("Required project info", { status: 404 });
|
||||
throw json("Required project info", { status: 400 });
|
||||
}
|
||||
|
||||
const cssText = await generateCssText(buildParams);
|
||||
|
||||
@@ -6,10 +6,7 @@ import { createCssEngine } from "@webstudio-is/css-engine";
|
||||
import { idAttribute } from "@webstudio-is/react-sdk";
|
||||
import type { BuildParams } from "../router-utils";
|
||||
|
||||
export const generateCssText = async (buildParams: BuildParams | undefined) => {
|
||||
if (buildParams === undefined) {
|
||||
throw json("Required project info", { status: 404 });
|
||||
}
|
||||
export const generateCssText = async (buildParams: BuildParams) => {
|
||||
const project = await db.project.loadByParams(buildParams);
|
||||
|
||||
if (project === null) {
|
||||
@@ -19,7 +16,7 @@ export const generateCssText = async (buildParams: BuildParams | undefined) => {
|
||||
const canvasData = await loadCanvasData(
|
||||
project,
|
||||
buildParams.mode === "published" ? "prod" : "dev",
|
||||
buildParams.pageId
|
||||
"pageId" in buildParams ? buildParams.pageId : buildParams.pagePath
|
||||
);
|
||||
|
||||
if (canvasData === undefined) {
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("getBuildOrigin", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCanvasRequestParams", () => {
|
||||
describe("getBuildParams", () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
BUILD_ORIGIN: "https://foo.com",
|
||||
@@ -86,13 +86,25 @@ describe("getCanvasRequestParams", () => {
|
||||
|
||||
test("detects path", () => {
|
||||
expect(
|
||||
getBuildParams(makeRequest("https://bar.foo.com"), env)?.pathname
|
||||
(
|
||||
getBuildParams(makeRequest("https://bar.foo.com"), env) as {
|
||||
pagePath: string;
|
||||
}
|
||||
).pagePath
|
||||
).toBe("/");
|
||||
expect(
|
||||
getBuildParams(makeRequest("https://bar.foo.com/abc"), env)?.pathname
|
||||
(
|
||||
getBuildParams(makeRequest("https://bar.foo.com/abc"), env) as {
|
||||
pagePath: string;
|
||||
}
|
||||
).pagePath
|
||||
).toBe("/abc");
|
||||
expect(
|
||||
getBuildParams(makeRequest("https://bar.foo.com/abc/123"), env)?.pathname
|
||||
(
|
||||
getBuildParams(makeRequest("https://bar.foo.com/abc/123"), env) as {
|
||||
pagePath: string;
|
||||
}
|
||||
).pagePath
|
||||
).toBe("/abc/123");
|
||||
});
|
||||
|
||||
|
||||
@@ -2,19 +2,8 @@
|
||||
// "Build" means user generated content — what user builds.
|
||||
|
||||
export type BuildMode = "edit" | "preview" | "published";
|
||||
export type BuildParams =
|
||||
| {
|
||||
projectId: string;
|
||||
mode: BuildMode;
|
||||
pathname: string;
|
||||
pageId?: string;
|
||||
}
|
||||
| {
|
||||
projectDomain: string;
|
||||
mode: BuildMode;
|
||||
pathname: string;
|
||||
pageId?: string;
|
||||
};
|
||||
export type BuildParams = ({ pagePath: string } | { pageId: string }) &
|
||||
({ projectId: string } | { projectDomain: string }) & { mode: BuildMode };
|
||||
|
||||
// A subtype of Request. To make testing easier.
|
||||
type MinimalRequest = {
|
||||
@@ -71,20 +60,7 @@ export const getBuildOrigin = (
|
||||
export const getBuildParams = (
|
||||
request: MinimalRequest,
|
||||
env = process.env
|
||||
):
|
||||
| {
|
||||
projectId: string;
|
||||
mode: BuildMode;
|
||||
pathname: string;
|
||||
pageId?: string;
|
||||
}
|
||||
| {
|
||||
projectDomain: string;
|
||||
mode: BuildMode;
|
||||
pathname: string;
|
||||
pageId?: string;
|
||||
}
|
||||
| undefined => {
|
||||
): BuildParams | undefined => {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const requestHost = getRequestHost(request);
|
||||
@@ -94,23 +70,32 @@ export const getBuildParams = (
|
||||
if (env.BUILD_REQUIRE_SUBDOMAIN !== "true") {
|
||||
const projectId = url.searchParams.get("projectId");
|
||||
if (projectId !== null && buildHost === requestHost) {
|
||||
return {
|
||||
projectId,
|
||||
mode: getMode(url),
|
||||
pathname: url.pathname,
|
||||
pageId,
|
||||
};
|
||||
return pageId === undefined
|
||||
? { projectId, mode: getMode(url), pagePath: url.pathname }
|
||||
: { projectId, mode: getMode(url), pageId };
|
||||
}
|
||||
}
|
||||
|
||||
const [projectDomain, ...rest] = requestHost.split(".");
|
||||
const baseHost = rest.join(".");
|
||||
if (baseHost === buildHost) {
|
||||
return {
|
||||
projectDomain,
|
||||
mode: getMode(url),
|
||||
pathname: url.pathname,
|
||||
pageId,
|
||||
};
|
||||
return pageId === undefined
|
||||
? { projectDomain, mode: getMode(url), pagePath: url.pathname }
|
||||
: { projectDomain, mode: getMode(url), pageId };
|
||||
}
|
||||
};
|
||||
|
||||
export const preserveSearchBuildParams = (
|
||||
source: URLSearchParams,
|
||||
target: URLSearchParams
|
||||
): void => {
|
||||
const mode = source.get("mode");
|
||||
if (typeof mode === "string" && modes.includes(mode as BuildMode)) {
|
||||
target.set("mode", mode);
|
||||
}
|
||||
|
||||
const projectId = source.get("projectId");
|
||||
if (projectId) {
|
||||
target.set("projectId", projectId);
|
||||
}
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
import { Image } from "./image";
|
||||
import { Link } from "./link";
|
||||
import { RichTextLink } from "./rich-text-link";
|
||||
import { imageProps } from "@webstudio-is/image";
|
||||
import type { MetaProps } from "../../components/component-type";
|
||||
|
||||
export const customComponents = {
|
||||
Image,
|
||||
Link,
|
||||
RichTextLink,
|
||||
};
|
||||
|
||||
export const customComponentsMeta = {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { Link as BaseLink } from "../../components/link";
|
||||
import { wrapLinkComponent } from "./shared/remix-link";
|
||||
|
||||
export const Link = wrapLinkComponent(BaseLink);
|
||||
@@ -0,0 +1,4 @@
|
||||
import { RichTextLink as BaseLink } from "../../components/rich-text-link";
|
||||
import { wrapLinkComponent } from "./shared/remix-link";
|
||||
|
||||
export const RichTextLink = wrapLinkComponent(BaseLink);
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import type {
|
||||
ElementRef,
|
||||
ComponentProps,
|
||||
RefAttributes,
|
||||
ForwardRefExoticComponent,
|
||||
} from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
const isAbsoluteUrl = (href: string) => {
|
||||
try {
|
||||
new URL(href);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
type Props = Omit<ComponentProps<"a">, "href"> & { href?: string };
|
||||
|
||||
type Ref = ElementRef<"a">;
|
||||
|
||||
export const wrapLinkComponent = (
|
||||
BaseLink: ForwardRefExoticComponent<Props & RefAttributes<Ref>>
|
||||
) => {
|
||||
// We're not actually wrapping BaseLink (no way to wrap with Remix's Link),
|
||||
// but this is still useful because we're making sure that props/ref types are compatible
|
||||
const Component = forwardRef<Ref, Props>(({ href = "", ...props }, ref) =>
|
||||
isAbsoluteUrl(href) ? (
|
||||
<a {...props} href={href} ref={ref} />
|
||||
) : (
|
||||
<Link {...props} to={href} ref={ref} />
|
||||
)
|
||||
);
|
||||
|
||||
// This is the only part that we use from BaseLink at runtime
|
||||
Component.displayName = BaseLink.displayName;
|
||||
|
||||
return Component;
|
||||
};
|
||||
@@ -1,12 +1,8 @@
|
||||
import { forwardRef, type ElementRef, type ComponentProps } from "react";
|
||||
|
||||
const defaultTag = "a";
|
||||
type Props = Omit<ComponentProps<"a">, "href"> & { href?: string };
|
||||
|
||||
type LinkProps = Omit<ComponentProps<typeof defaultTag>, "href"> & {
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export const Link = forwardRef<ElementRef<typeof defaultTag>, LinkProps>(
|
||||
export const Link = forwardRef<ElementRef<"a">, Props>(
|
||||
({ href = "", ...props }, ref) => <a {...props} href={href} ref={ref} />
|
||||
);
|
||||
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { forwardRef, type ElementRef, type ComponentProps } from "react";
|
||||
|
||||
const defaultTag = "a";
|
||||
type Props = Omit<ComponentProps<"a">, "href"> & { href?: string };
|
||||
|
||||
type RichTextLinkProps = Omit<ComponentProps<typeof defaultTag>, "href"> & {
|
||||
href?: string;
|
||||
};
|
||||
|
||||
export const RichTextLink = forwardRef<
|
||||
ElementRef<typeof defaultTag>,
|
||||
RichTextLinkProps
|
||||
>(({ href = "", ...props }, ref) => <a {...props} href={href} ref={ref} />);
|
||||
export const RichTextLink = forwardRef<ElementRef<"a">, Props>(
|
||||
({ href = "", ...props }, ref) => <a {...props} href={href} ref={ref} />
|
||||
);
|
||||
|
||||
RichTextLink.displayName = "RichTextLink";
|
||||
@@ -6,7 +6,7 @@ import type { Asset } from "@webstudio-is/asset-uploader";
|
||||
import { type ComponentProps } from "react";
|
||||
import type { Breakpoint } from "@webstudio-is/css-data";
|
||||
import { registerComponents } from "../components";
|
||||
import { customComponents } from "../app/custom-components";
|
||||
import { customComponents as defaultCustomComponents } from "../app/custom-components";
|
||||
import { setParams, type Params } from "../app/params";
|
||||
|
||||
export type Data = {
|
||||
@@ -20,11 +20,13 @@ export type Data = {
|
||||
type RootProps = {
|
||||
data: Data;
|
||||
Component?: (props: ComponentProps<typeof WrapperComponent>) => JSX.Element;
|
||||
customComponents?: Parameters<typeof registerComponents>[0];
|
||||
};
|
||||
|
||||
export const InstanceRoot = ({
|
||||
data,
|
||||
Component,
|
||||
customComponents = defaultCustomComponents,
|
||||
}: RootProps): JSX.Element | null => {
|
||||
if (data.tree === null) {
|
||||
throw new Error("Tree is null");
|
||||
|
||||
Reference in new issue
Block a user