mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-24 12:07:17 +09:00
fix: Keep MDX edits and generated previews reliable
This commit is contained in:
commit
ab3a6b7c55
26 files changed
+397
-113
No files matched your search
@@ -49,9 +49,7 @@ import {
|
||||
} from "~/shared/nano-states";
|
||||
import { $selectedPageId } from "~/shared/nano-states";
|
||||
import {
|
||||
$authToken,
|
||||
$authTokenPermissions,
|
||||
$builderMode,
|
||||
$editingPageId,
|
||||
$permissions,
|
||||
$stagingUsername,
|
||||
@@ -93,7 +91,7 @@ import { RelativeTime } from "~/builder/shared/relative-time";
|
||||
import cmsUpgradeBanner from "~/shared/cms-upgrade-banner.svg?url";
|
||||
import { $currentSystem } from "~/shared/system";
|
||||
import { getPublishUrl } from "./publish-url";
|
||||
import { builderUrl } from "~/shared/router-utils";
|
||||
import { getInstanceLink } from "~/shared/instance-utils/link";
|
||||
import {
|
||||
getRestrictedFeatures,
|
||||
type RestrictedFeature,
|
||||
@@ -117,12 +115,10 @@ const PrePublishAuditMessage = ({
|
||||
const { instanceId } = finding.location;
|
||||
const pages = $pages.get();
|
||||
const instances = $instances.get();
|
||||
const project = $project.get();
|
||||
|
||||
if (
|
||||
instanceId === undefined ||
|
||||
pages === undefined ||
|
||||
project === undefined ||
|
||||
instances.has(instanceId) === false
|
||||
) {
|
||||
return message;
|
||||
@@ -133,14 +129,10 @@ const PrePublishAuditMessage = ({
|
||||
instances,
|
||||
instanceId
|
||||
);
|
||||
const href = builderUrl({
|
||||
projectId: project.id,
|
||||
pageId: pageId === pages.homePageId ? undefined : pageId,
|
||||
instanceSelector,
|
||||
origin: window.location.origin,
|
||||
authToken: $authToken.get(),
|
||||
mode: $builderMode.get(),
|
||||
});
|
||||
const href = getInstanceLink(instanceSelector);
|
||||
if (href === undefined) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -14,10 +14,12 @@ import {
|
||||
theme,
|
||||
Kbd,
|
||||
Box,
|
||||
toast,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { showAttribute } from "@webstudio-is/react-sdk";
|
||||
import { emitCommand, instanceMoveCommandMetas } from "./commands";
|
||||
import {
|
||||
$canOpenPageTemplates,
|
||||
$allSelectedInstanceSelectors,
|
||||
$selectedInstancePath,
|
||||
$selectedPage,
|
||||
@@ -31,7 +33,11 @@ import {
|
||||
type InstancePath,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { canDeleteInstanceInContentMode } from "@webstudio-is/project-build/runtime";
|
||||
import { $instances } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
getInstanceLink,
|
||||
getDeepLinkedInstanceSelection,
|
||||
} from "~/shared/instance-utils/link";
|
||||
import { $instances, $pages, $project } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
isComponentDetachable,
|
||||
ROOT_INSTANCE_ID,
|
||||
@@ -130,6 +136,9 @@ const getMenuPermissions = ({
|
||||
|
||||
export const MenuItems = () => {
|
||||
const instancePath = useStore($selectedInstancePath);
|
||||
const pages = useStore($pages);
|
||||
const project = useStore($project);
|
||||
const canOpenPageTemplates = useStore($canOpenPageTemplates);
|
||||
const selectedInstanceSelectors = useStore($allSelectedInstanceSelectors);
|
||||
const instances = useStore($instances);
|
||||
const propValues = useStore($propValuesByInstanceSelector);
|
||||
@@ -138,6 +147,15 @@ export const MenuItems = () => {
|
||||
|
||||
const instanceSelector = instancePath?.[0]?.instanceSelector;
|
||||
|
||||
const instanceSelection =
|
||||
pages &&
|
||||
getDeepLinkedInstanceSelection({
|
||||
instanceSelector,
|
||||
canOpenPageTemplates,
|
||||
pages,
|
||||
instances,
|
||||
});
|
||||
|
||||
const show = instanceSelector
|
||||
? Boolean(
|
||||
propValues.get(getInstanceKey(instanceSelector))?.get(showAttribute) ??
|
||||
@@ -174,6 +192,23 @@ export const MenuItems = () => {
|
||||
/>
|
||||
</ContextMenuItemRightSlot>
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={instanceSelection === undefined || project === undefined}
|
||||
onSelect={async () => {
|
||||
const link = getInstanceLink(instanceSelector);
|
||||
if (link === undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
toast.success("Link copied");
|
||||
} catch {
|
||||
toast.error("Could not copy link");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy link to instance
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
disabled={!permissions.canPaste}
|
||||
onSelect={() => {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { createContext } from "~/shared/context.server";
|
||||
import { getPlanInfo } from "@webstudio-is/plans/index.server";
|
||||
import { defaultPlanFeatures } from "@webstudio-is/plans";
|
||||
import { dashboardPath, isBuilder, isDashboard } from "~/shared/router-utils";
|
||||
import { areBuilderDataUrlsEqual } from "~/shared/router-utils/path-utils";
|
||||
|
||||
import env from "~/env/env.server";
|
||||
|
||||
@@ -327,23 +328,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
nextUrl,
|
||||
defaultShouldRevalidate,
|
||||
}) => {
|
||||
const currentUrlCopy = new URL(currentUrl);
|
||||
const nextUrlCopy = new URL(nextUrl);
|
||||
// prevent revalidating data when pageId changes
|
||||
// to not regenerate auth token and preserve canvas url
|
||||
currentUrlCopy.searchParams.delete("pageId");
|
||||
nextUrlCopy.searchParams.delete("pageId");
|
||||
|
||||
currentUrlCopy.searchParams.delete("instanceId");
|
||||
nextUrlCopy.searchParams.delete("instanceId");
|
||||
|
||||
currentUrlCopy.searchParams.delete("mode");
|
||||
nextUrlCopy.searchParams.delete("mode");
|
||||
|
||||
currentUrlCopy.searchParams.delete("pageHash");
|
||||
nextUrlCopy.searchParams.delete("pageHash");
|
||||
|
||||
return currentUrlCopy.href === nextUrlCopy.href
|
||||
return areBuilderDataUrlsEqual(currentUrl, nextUrl)
|
||||
? false
|
||||
: defaultShouldRevalidate;
|
||||
};
|
||||
|
||||
@@ -2536,6 +2536,23 @@ test("materializes into normal instances and saves their synchronous mutations o
|
||||
})?.identity;
|
||||
expect(savedIdentity?.contentRef).toBe("article_v2.mdx");
|
||||
expect(savedIdentity?.revision).not.toBe(initialIdentity?.revision);
|
||||
// An edit can arrive as soon as its predecessor is acknowledged, while
|
||||
// asynchronous materialization of the new revision is still in flight.
|
||||
const unsubscribe = session.subscribe((assetId, state) => {
|
||||
if (assetId !== asset.id || state.status !== "saved") {
|
||||
return;
|
||||
}
|
||||
unsubscribe();
|
||||
executeRuntimeMutation({
|
||||
id: "instances.setTextContent",
|
||||
input: {
|
||||
operation: "set",
|
||||
instanceId: insertedId,
|
||||
mode: "text",
|
||||
text: "Second after acknowledged save",
|
||||
},
|
||||
});
|
||||
});
|
||||
executeRuntimeMutation({
|
||||
id: "instances.setTextContent",
|
||||
input: {
|
||||
@@ -2547,7 +2564,11 @@ test("materializes into normal instances and saves their synchronous mutations o
|
||||
});
|
||||
expect(serverSyncStore.popAll()).toEqual([]);
|
||||
await flushExternalContentAsset({ projectId: "project", assetId: asset.id });
|
||||
expect(writes.at(-1)).toBe("# Updated\n\n## Second after save\n");
|
||||
await flushExternalContentAsset({ projectId: "project", assetId: asset.id });
|
||||
expect(requireReload).not.toHaveBeenCalled();
|
||||
expect(writes.at(-1)).toBe(
|
||||
"# Updated\n\n## Second after acknowledged save\n"
|
||||
);
|
||||
expect(
|
||||
getExternalContentRootChildren({
|
||||
projectId: "project",
|
||||
|
||||
@@ -885,14 +885,21 @@ const rematerializeAsset = async (
|
||||
preservedEntry?.projectId === projectId &&
|
||||
preservedEntry.assetId === assetId
|
||||
) {
|
||||
// This is our own document's save acknowledgment. Advance its revision
|
||||
// before async parsing so the next local edit does not look like a conflict.
|
||||
const version = ++preservedEntry.openVersion;
|
||||
preservedEntry.root = {
|
||||
...preservedEntry.root,
|
||||
identity: createExternalContentIdentity(preservedEntry, sourceState),
|
||||
};
|
||||
registerMutationRoot(preservedEntry, preservedEntry.installedFragment);
|
||||
const result = await materialize({ entry: preservedEntry, sourceState });
|
||||
if (roots.get(preservedEntry.key) === preservedEntry) {
|
||||
if (
|
||||
roots.get(preservedEntry.key) === preservedEntry &&
|
||||
preservedEntry.openVersion === version
|
||||
) {
|
||||
// Preserve the synchronously edited fragment and refresh only the
|
||||
// source-owned state that does not participate in the current selection.
|
||||
preservedEntry.root = {
|
||||
...preservedEntry.root,
|
||||
identity: createExternalContentIdentity(preservedEntry, sourceState),
|
||||
};
|
||||
preservedEntry.diagnostics = result.diagnostics;
|
||||
registerMutationRoot(preservedEntry, preservedEntry.installedFragment);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
getAllPages,
|
||||
isPageTemplate,
|
||||
type Instances,
|
||||
type Pages,
|
||||
} from "@webstudio-is/sdk";
|
||||
import type { InstanceSelector } from "@webstudio-is/project-build/runtime";
|
||||
import {
|
||||
$authToken,
|
||||
$builderMode,
|
||||
$canOpenPageTemplates,
|
||||
} from "../nano-states";
|
||||
import { builderUrl } from "../router-utils";
|
||||
import { $instances, $pages, $project } from "../sync/data-stores";
|
||||
import { canResolveInstanceSelector } from "./selection";
|
||||
|
||||
export const getInstanceLink = (
|
||||
instanceSelector: InstanceSelector | undefined
|
||||
) => {
|
||||
const pages = $pages.get();
|
||||
const project = $project.get();
|
||||
if (pages === undefined || project === undefined) {
|
||||
return;
|
||||
}
|
||||
const selection = getDeepLinkedInstanceSelection({
|
||||
instanceSelector,
|
||||
pages,
|
||||
instances: $instances.get(),
|
||||
canOpenPageTemplates: $canOpenPageTemplates.get(),
|
||||
});
|
||||
if (selection === undefined) {
|
||||
return;
|
||||
}
|
||||
const mode = $builderMode.get();
|
||||
return builderUrl({
|
||||
...selection,
|
||||
projectId: project.id,
|
||||
origin: window.location.origin,
|
||||
authToken: $authToken.get(),
|
||||
mode: mode === "design" ? undefined : mode,
|
||||
safemode:
|
||||
new URLSearchParams(window.location.search).get("safemode") === "true",
|
||||
});
|
||||
};
|
||||
|
||||
export const getInstanceSelectorFromUrl = (searchParams: URLSearchParams) =>
|
||||
searchParams.get("instance")?.split(",");
|
||||
|
||||
export const getDeepLinkedInstanceSelection = ({
|
||||
instanceSelector,
|
||||
canOpenPageTemplates,
|
||||
pages,
|
||||
instances,
|
||||
}: {
|
||||
instanceSelector: InstanceSelector | undefined;
|
||||
canOpenPageTemplates: boolean;
|
||||
pages: Pages;
|
||||
instances: Instances;
|
||||
}) => {
|
||||
if (instanceSelector === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = instanceSelector[0];
|
||||
if (
|
||||
instanceId === undefined ||
|
||||
instances.has(instanceId) === false ||
|
||||
canResolveInstanceSelector(instanceSelector, instances) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootInstanceId = instanceSelector.at(-1);
|
||||
const page = getAllPages(pages).find(
|
||||
(page) => page.rootInstanceId === rootInstanceId
|
||||
);
|
||||
if (
|
||||
page === undefined ||
|
||||
(isPageTemplate(page) && canOpenPageTemplates === false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return { pageId: page.id, instanceSelector };
|
||||
};
|
||||
@@ -1,19 +1,31 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import { cleanStores } from "nanostores";
|
||||
import type { Project } from "@webstudio-is/project";
|
||||
import { createDefaultPages } from "@webstudio-is/project-build";
|
||||
import {
|
||||
createTemplateComponentFixture,
|
||||
renderData,
|
||||
} from "@webstudio-is/template";
|
||||
import { $authToken, $builderMode } from "~/shared/nano-states";
|
||||
import { $instances, $pages, $project } from "~/shared/sync/data-stores";
|
||||
import {
|
||||
getInstanceLink,
|
||||
getDeepLinkedInstanceSelection,
|
||||
getInstanceSelectorFromUrl,
|
||||
} from "../instance-utils/link";
|
||||
import { __testing__ } from "./use-switch-page";
|
||||
|
||||
afterEach(() => {
|
||||
cleanStores($authToken, $builderMode, $instances, $pages, $project);
|
||||
});
|
||||
|
||||
const Body = createTemplateComponentFixture("Body");
|
||||
const Box = createTemplateComponentFixture("Box");
|
||||
const Fragment = createTemplateComponentFixture("Fragment");
|
||||
const Heading = createTemplateComponentFixture("Heading");
|
||||
const Slot = createTemplateComponentFixture("Slot");
|
||||
|
||||
const { getDeepLinkedInstanceSelection, shouldNavigateToPageState } =
|
||||
__testing__;
|
||||
const { shouldNavigateToPageState } = __testing__;
|
||||
|
||||
test("preserves an instance deep link until URL state is initialized", () => {
|
||||
expect(
|
||||
@@ -91,16 +103,27 @@ test("restores the selected shared slot occurrence from its full selector", () =
|
||||
</Body>
|
||||
);
|
||||
|
||||
$pages.set(pages);
|
||||
$instances.set(instances);
|
||||
$project.set({ id: "090e6e14-ae50-4b2e-bd22-71733cec05bb" } as Project);
|
||||
$authToken.set("share-token");
|
||||
$builderMode.set("content");
|
||||
const link = getInstanceLink(["box", "fragment", "slot-two", "body"]);
|
||||
expect(link).toBeDefined();
|
||||
const url = new URL(link!);
|
||||
expect(url.hostname).toContain("p-090e6e14-ae50-4b2e-bd22-71733cec05bb");
|
||||
expect(url.searchParams.get("authToken")).toBe("share-token");
|
||||
expect(url.searchParams.get("mode")).toBe("content");
|
||||
expect(
|
||||
getDeepLinkedInstanceSelection({
|
||||
instanceSelector: ["box", "fragment", "slot-one", "body"],
|
||||
instanceSelector: getInstanceSelectorFromUrl(url.searchParams),
|
||||
canOpenPageTemplates: true,
|
||||
pages,
|
||||
instances,
|
||||
})
|
||||
).toEqual({
|
||||
pageId: "home-page",
|
||||
instanceSelector: ["box", "fragment", "slot-one", "body"],
|
||||
instanceSelector: ["box", "fragment", "slot-two", "body"],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,55 +16,16 @@ import {
|
||||
import { builderPath } from "~/shared/router-utils";
|
||||
import { $selectedPage } from "../nano-states";
|
||||
import { selectPage } from "../nano-states";
|
||||
import {
|
||||
findPageByIdOrPath,
|
||||
getAllPages,
|
||||
isPageTemplate,
|
||||
type Instances,
|
||||
type Pages,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { findPageByIdOrPath, isPageTemplate } from "@webstudio-is/sdk";
|
||||
import {
|
||||
areInstanceSelectorsEqual,
|
||||
type InstanceSelector,
|
||||
} from "@webstudio-is/project-build/runtime";
|
||||
import { canResolveInstanceSelector } from "../instance-utils/selection";
|
||||
|
||||
const getDeepLinkedInstanceSelection = ({
|
||||
instanceSelector,
|
||||
canOpenPageTemplates,
|
||||
pages,
|
||||
instances,
|
||||
}: {
|
||||
instanceSelector: InstanceSelector | undefined;
|
||||
canOpenPageTemplates: boolean;
|
||||
pages: Pages;
|
||||
instances: Instances;
|
||||
}) => {
|
||||
if (instanceSelector === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const instanceId = instanceSelector[0];
|
||||
if (
|
||||
instanceId === undefined ||
|
||||
instances.has(instanceId) === false ||
|
||||
canResolveInstanceSelector(instanceSelector, instances) === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rootInstanceId = instanceSelector.at(-1);
|
||||
const page = getAllPages(pages).find(
|
||||
(page) => page.rootInstanceId === rootInstanceId
|
||||
);
|
||||
if (
|
||||
page === undefined ||
|
||||
(isPageTemplate(page) && canOpenPageTemplates === false)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return { pageId: page.id, instanceSelector };
|
||||
};
|
||||
import {
|
||||
getDeepLinkedInstanceSelection,
|
||||
getInstanceSelectorFromUrl,
|
||||
} from "../instance-utils/link";
|
||||
|
||||
const shouldNavigateToPageState = ({
|
||||
isUrlStateInitialized,
|
||||
@@ -87,13 +48,9 @@ const shouldNavigateToPageState = ({
|
||||
) === false));
|
||||
|
||||
export const __testing__ = {
|
||||
getDeepLinkedInstanceSelection,
|
||||
shouldNavigateToPageState,
|
||||
};
|
||||
|
||||
const getInstanceSelectorFromUrl = (searchParams: URLSearchParams) =>
|
||||
searchParams.get("instance")?.split(",");
|
||||
|
||||
const setPageStateFromUrl = () => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const pages = $pages.get();
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { builderPath, builderUrl, restAssetsUploadPath } from "./path-utils";
|
||||
import {
|
||||
builderPath,
|
||||
builderUrl,
|
||||
restAssetsUploadPath,
|
||||
areBuilderDataUrlsEqual,
|
||||
} from "./path-utils";
|
||||
|
||||
test("includes an instance deep link in builder paths", () => {
|
||||
expect(
|
||||
@@ -31,3 +36,23 @@ test("builds the asset upload item route", () => {
|
||||
"/rest/assets/uploads/query?width=100&height=200"
|
||||
);
|
||||
});
|
||||
|
||||
test("instance navigation preserves Builder data but access changes do not", () => {
|
||||
const current = new URL(
|
||||
builderPath({
|
||||
instanceSelector: ["first", "body"],
|
||||
authToken: "share-token",
|
||||
}),
|
||||
"https://wstd.dev"
|
||||
);
|
||||
const next = new URL(
|
||||
builderPath({
|
||||
instanceSelector: ["second", "body"],
|
||||
authToken: "share-token",
|
||||
}),
|
||||
current
|
||||
);
|
||||
expect(areBuilderDataUrlsEqual(current, next)).toBe(true);
|
||||
next.searchParams.set("authToken", "other-token");
|
||||
expect(areBuilderDataUrlsEqual(current, next)).toBe(false);
|
||||
});
|
||||
@@ -158,3 +158,20 @@ export const restResourcesLoader = ({ diagnostics = false } = {}) =>
|
||||
|
||||
export const marketplacePath = (method: string) =>
|
||||
`/builder/marketplace/${method}`;
|
||||
|
||||
// Editor navigation does not change the Builder data loaded by the route.
|
||||
export const areBuilderDataUrlsEqual = (currentUrl: URL, nextUrl: URL) => {
|
||||
const current = new URL(currentUrl);
|
||||
const next = new URL(nextUrl);
|
||||
for (const param of [
|
||||
"pageId",
|
||||
"instance",
|
||||
"instanceId",
|
||||
"mode",
|
||||
"pageHash",
|
||||
]) {
|
||||
current.searchParams.delete(param);
|
||||
next.searchParams.delete(param);
|
||||
}
|
||||
return current.href === next.href;
|
||||
};
|
||||
@@ -37,12 +37,22 @@ keeps the remaining selection intact.
|
||||
|
||||
### Link to a selected instance
|
||||
|
||||
The Builder URL tracks the selected page and instance. Copy the browser URL to
|
||||
share a link that opens the same page and selects the same instance. Webstudio
|
||||
also uses these links for actions such as **Show element** in pre-publish
|
||||
Right-click an instance and choose **Copy link to instance** to copy its full
|
||||
Builder URL to the clipboard. Share the link to open the same page and select
|
||||
the same instance, including its location inside a shared Slot. Select one
|
||||
instance at a time; this action is unavailable for Global Root.
|
||||
|
||||
The recipient needs access to the project. If you opened the Builder through a
|
||||
share link, the copied URL keeps that share link’s access token.
|
||||
|
||||
You can also copy the browser URL, which tracks the selected page and instance.
|
||||
Webstudio also uses these links for actions such as **Show element** in pre-publish
|
||||
findings. If the instance was removed before the link is opened, the Builder
|
||||
still opens the requested page.
|
||||
|
||||
You can give this link to an AI assistant connected to the project through
|
||||
[MCP](../mcp.md) to identify the instance you want to change.
|
||||
|
||||
## Renaming instances
|
||||
|
||||
Double-click any item in the Navigator to rename it. Use descriptive names like "Hero Section" or "Product Card" to make large projects easier to navigate.
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
"fixtures:link": "pnpm cli:local link --link https://p-d845c167-ea07-4875-b08d-83e97c09dcce-dot-${BUILDER_HOST:-main.development.webstudio.is}'?authToken=e9d1343f-9298-4fd3-a66e-f89a5af2dd93'",
|
||||
"fixtures:sync": "pnpm cli:local sync --buildId f565d527-32e7-4731-bc71-aca9e9574587 && oxfmt ./.webstudio/",
|
||||
"fixtures:build": "pnpm cli:local build --template cloudflare-new --template .template && oxfmt ./app/ ./package.json ./tsconfig.json",
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"build": "node ./node_modules/@react-router/dev/bin.js build",
|
||||
"dev": "node ./node_modules/@react-router/dev/bin.js dev",
|
||||
"typegen": "wrangler types",
|
||||
"preview": "react-router build && vite preview",
|
||||
"deploy": "react-router build && wrangler deploy",
|
||||
"start": "react-router-serve ./build/server/index.js"
|
||||
"start": "node ./node_modules/@react-router/serve/bin.js ./build/server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.1.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"build": "node ./node_modules/@react-router/dev/bin.js build",
|
||||
"dev": "node ./node_modules/@react-router/dev/bin.js dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"cli": "NODE_OPTIONS='--conditions=webstudio --import=tsx' webstudio",
|
||||
|
||||
@@ -5,6 +5,8 @@ const sourceConditions =
|
||||
process.env.WEBSTUDIO_LOCAL_CLI_BOOTSTRAPPED === "1" ? ["webstudio"] : [];
|
||||
|
||||
export default defineConfig({
|
||||
// Local previews share node_modules, but must not share optimizer state.
|
||||
cacheDir: ".react-router/vite",
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
hmr: process.env.WEBSTUDIO_PREVIEW_HMR === "disabled" ? false : undefined,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"fixtures:link": "pnpm cli:local link --link https://p-d845c167-ea07-4875-b08d-83e97c09dcce-dot-${BUILDER_HOST:-main.development.webstudio.is}'?authToken=e9d1343f-9298-4fd3-a66e-f89a5af2dd93'",
|
||||
"fixtures:sync": "pnpm cli:local sync --buildId f565d527-32e7-4731-bc71-aca9e9574587 && oxfmt ./.webstudio/",
|
||||
"fixtures:build": "pnpm cli:local build --template netlify --template .template && oxfmt ./app/ ./package.json ./tsconfig.json",
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"build": "node ./node_modules/@react-router/dev/bin.js build",
|
||||
"dev": "node ./node_modules/@react-router/dev/bin.js dev",
|
||||
"start": "npx netlify-cli serve",
|
||||
"deploy": "npx netlify-cli deploy --build --prod"
|
||||
},
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
"fixtures:link": "pnpm cli:local link --link https://p-d845c167-ea07-4875-b08d-83e97c09dcce-dot-${BUILDER_HOST:-main.development.webstudio.is}'?authToken=e9d1343f-9298-4fd3-a66e-f89a5af2dd93'",
|
||||
"fixtures:sync": "pnpm cli:local sync --buildId f565d527-32e7-4731-bc71-aca9e9574587 && oxfmt ./.webstudio/",
|
||||
"fixtures:build": "pnpm cli:local build --template vercel --template .template && oxfmt ./app/ ./package.json ./tsconfig.json",
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"build": "node ./node_modules/@react-router/dev/bin.js build",
|
||||
"dev": "node ./node_modules/@react-router/dev/bin.js dev",
|
||||
"deploy": "npx vercel deploy",
|
||||
"start": "react-router-serve ./build/server/index.js"
|
||||
"start": "node ./node_modules/@react-router/serve/bin.js ./build/server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
|
||||
@@ -5,6 +5,8 @@ const sourceConditions =
|
||||
process.env.WEBSTUDIO_LOCAL_CLI_BOOTSTRAPPED === "1" ? ["webstudio"] : [];
|
||||
|
||||
export default defineConfig({
|
||||
// Local previews share node_modules, but must not share optimizer state.
|
||||
cacheDir: ".react-router/vite",
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
hmr: process.env.WEBSTUDIO_PREVIEW_HMR === "disabled" ? false : undefined,
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"build": "node ./node_modules/@react-router/dev/bin.js build",
|
||||
"dev": "node ./node_modules/@react-router/dev/bin.js dev",
|
||||
"cli": "NODE_OPTIONS='--conditions=webstudio --import=tsx' webstudio",
|
||||
"cli:local": "node ../../packages/cli/local.js",
|
||||
"fixtures:link": "pnpm cli:local link --link https://p-cddc1d44-af37-4cb6-a430-d300cf6f932d-dot-${BUILDER_HOST:-main.development.webstudio.is}'?authToken=1cdc6026-dd5b-4624-b89b-9bd45e9bcc3d'",
|
||||
"fixtures:sync": "pnpm cli:local sync --buildId 1b66ee06-8ea5-4420-9a0e-e0d3a67aca32 && oxfmt ./.webstudio/",
|
||||
"fixtures:build": "pnpm cli:local build --template react-router --template ./.template && oxfmt ./app/ ./package.json ./tsconfig.json",
|
||||
"start": "react-router-serve ./build/server/index.js"
|
||||
"start": "node ./node_modules/@react-router/serve/bin.js ./build/server/index.js"
|
||||
},
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
@@ -26,6 +28,79 @@ import {
|
||||
previewDefaultTemplate,
|
||||
} from "./preview";
|
||||
import { generatedFilesManifest } from "../prebuild";
|
||||
import { getPackageManagerInvocation } from "../preview-server/package-manager";
|
||||
import { resolveConfig } from "vite";
|
||||
import reactRouterConfig from "../../templates/react-router/vite.config";
|
||||
|
||||
test("keeps generated preview caches separate when dependencies are shared", async () => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "webstudio-preview-cache-"));
|
||||
try {
|
||||
const cachePaths: string[] = [];
|
||||
for (const name of ["first", "second"]) {
|
||||
const projectDir = join(directory, name);
|
||||
await mkdir(projectDir);
|
||||
await writeFile(
|
||||
join(projectDir, "package.json"),
|
||||
await readFile(
|
||||
new URL("../../templates/react-router/package.json", import.meta.url)
|
||||
)
|
||||
);
|
||||
await ensurePreviewDependencies(projectDir);
|
||||
const config = await resolveConfig(
|
||||
{
|
||||
...reactRouterConfig,
|
||||
root: projectDir,
|
||||
configFile: false,
|
||||
plugins: [],
|
||||
},
|
||||
"serve"
|
||||
);
|
||||
await mkdir(config.cacheDir, { recursive: true });
|
||||
cachePaths.push(await realpath(config.cacheDir));
|
||||
}
|
||||
expect(cachePaths[0]).not.toBe(cachePaths[1]);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["build", "dev", "start"])(
|
||||
"runs the React Router %s script with linked development dependencies",
|
||||
async (script) => {
|
||||
const directory = await mkdtemp(join(tmpdir(), "webstudio-preview-bin-"));
|
||||
try {
|
||||
await writeFile(
|
||||
join(directory, "package.json"),
|
||||
await readFile(
|
||||
new URL("../../templates/react-router/package.json", import.meta.url)
|
||||
)
|
||||
);
|
||||
await ensurePreviewDependencies(directory);
|
||||
// The start script must reach the generated server module, not fail in
|
||||
// a package-manager launcher before loading it.
|
||||
await mkdir(join(directory, "build", "server"), { recursive: true });
|
||||
await writeFile(
|
||||
join(directory, "build", "server", "index.js"),
|
||||
'import { writeFileSync } from "node:fs"; writeFileSync("started", "yes"); process.exit(0);'
|
||||
);
|
||||
const invocation = getPackageManagerInvocation([
|
||||
"run",
|
||||
script,
|
||||
...(script === "start" ? [] : ["--", "--help"]),
|
||||
]);
|
||||
await promisify(execFile)(invocation.command, invocation.args, {
|
||||
cwd: directory,
|
||||
env: { ...process.env, PORT: "3000" },
|
||||
timeout: 20_000,
|
||||
});
|
||||
if (script === "start") {
|
||||
expect(await readFile(join(directory, "started"), "utf8")).toBe("yes");
|
||||
}
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const readWindowsPackageFile = (path: string) => {
|
||||
if (path.endsWith("node_modules\\pnpm\\package.json")) {
|
||||
|
||||
@@ -351,6 +351,9 @@ export const ensurePreviewDependencies = async (
|
||||
if (usesDevelopmentDependencies) {
|
||||
for (const cliNodeModules of cliNodeModulesCandidates) {
|
||||
if (await hasRequiredDependencies(cliNodeModules)) {
|
||||
// React Router scripts invoke package binaries through Node: pnpm's .bin
|
||||
// wrappers use paths relative to the original installation and cannot
|
||||
// be relocated through this directory link.
|
||||
await operations.symlink(
|
||||
cliNodeModules,
|
||||
previewNodeModules,
|
||||
|
||||
@@ -6,7 +6,7 @@ export const cliDocs = {
|
||||
"manual-api":
|
||||
'# Webstudio API CLI Manual\n\nThe API commands operate on the single project configured by:\n\n- .webstudio/config.json: projectId\n- global Webstudio config: origin and token\n\n- Pass --json to API/discovery commands that support it. Do not add --json to top-level commands unless their help/schema documents it.\n- Never pass a project id. Commands use configured project only.\n- Read ids before writing. Do not invent ids for existing records.\n- stdout is one JSON object. stderr is diagnostics.\n- Prefer MCP semantic tools for detailed project edits. Use MCP apply-patch only when no semantic tool exists.\n\n## Start\n\n{{start}}\n\n## Read First\n\n{{readFirst}}\n\n## Project Session Cache\n\n- CLI commands use one local ProjectSession snapshot for the configured project.\n- Local-capable reads use cached namespaces when compatible and fetch only missing or stale namespaces.\n- Local-capable mutations build patches from the local snapshot, then commit with the cached build version.\n- Successful mutation commits update the local snapshot only after the remote commit succeeds.\n- Server-only commands run remotely and invalidate/refetch namespaces declared by the operation catalog.\n- Use --refresh on local-capable commands to refresh required namespaces before running.\n- Successful JSON responses include compact meta.session with operationId, buildId, version, source, committed, namespaceCounts, diagnosticCount, non-empty diagnostic summaries, and optional compatibilityVersion.\n\n## Search project data\n\nRun `webstudio search-project \'{"query":"known value"}\'` to search all Builder\nnamespaces in the local project session. Narrow it with\n`{"query":"Brand","namespaces":["styleSources","styles"]}` when only specific\nnamespaces are relevant. Results contain the current value, stable match id,\nnamespace path, owning entity, resolved references, and affected pages.\n\nValues in recognized credential fields are excluded and reported in\n`excludedSensitiveValueCount`.\n\nThe command synchronizes its required Builder namespaces, then searches locally\nand returns only matches. Namespace filters limit values matched; related\nnamespaces may still supply route and reference context, and synchronization is\nunchanged. This reduces data sent to the model, not project synchronization. It\nsearches asset metadata, not asset binaries or document contents.\n\n## CLI Capability Inventory\n\nFor a short end-consumer summary of what MCP can do, see\n`manual mcp` / `webstudio man mcp`. The MCP inventory describes the same\nproject, page, element, style, data, asset, publish, domain, and visual\nverification capabilities without internal command names.\n\n### Top-Level Commands\n\n{{topLevelCapabilityIndex}}\n\n### High-Level API Commands By Area\n\n{{apiCapabilityIndex}}\n\n### MCP Tool Operations\n\nThese are MCP tools. From a shell, call them with the shortcut form `webstudio <tool> \'<json>\'` or with the explicit form `webstudio mcp single-op-call <tool> \'<json>\'`:\n\n{{mcpOnlyCommandIndex}}\n\n## Task Recipes\n\n{{taskRecipeIndex}}\n\n## Use Case Index\n\n{{useCaseIndex}}\n\n## Known CLI Gaps\n\n{{knownCliGapIndex}}\n\n## Input File Shapes\n\n{{inputFileShapeIndex}}\n\n## Raw Patch Fallback\n\napply-patch accepts either BuildPatchTransaction[] or { "transactions": BuildPatchTransaction[] }.\n\nEach transaction has:\n\n{\n"id": "patch-transaction-label",\n"payload": [\n{\n"namespace": "projectSettings",\n"patches": [\n{ "op": "replace", "path": ["meta", "siteName"], "value": "New Site" }\n]\n}\n]\n}\n\nThe transaction id is a patch label used for optimistic synchronization. It is\nnot a Builder record id. Do not invent ids for pages, instances, props,\nbreakpoints, resources, variables, folders, assets, or other project records.\n\nPatch paths are JSON-patch-like paths into Builder store data. Map-like namespaces use ids as the first path item.\n\nSupported namespaces:\n\n- pages: redirects, page records, and folders\n- projectSettings: project-wide metadata and compiler settings\n- instances: element instances and children, including text/expression children\n- props: element props, bindings, page references, resource bindings\n- styles: CSS declarations keyed by style declaration key\n- styleSources: local style sources and reusable design tokens\n- styleSourceSelections: instance-to-style-source connections\n- dataSources: data variables, parameters, and resource data sources\n- resources: data resource definitions\n- assets: project asset records handled by the existing asset patch path\n- breakpoints: responsive breakpoints\n- marketplaceProduct: marketplace metadata\n\n## Data Sources\n\n`dataSources` is the internal Builder namespace for variables. Public API, CLI,\nand MCP tools expose it through two user-facing groups:\n\n- data variables: `list-variables`, `create-variable`, `update-variable`, and\n `delete-variable`\n- data resources: `list-resources`, `create-resource`, `update-resource`, and\Line truncated
|
||||
"manual-llm":
|
||||
'# Webstudio CLI Manual for LLMs\n\nUse this order. Stop only when a command returns ok:false.\n\nIf you are inside the Webstudio monorepo, the first command discovery should use\nthe local CLI exactly as `node packages/cli/local.js ...` from the repo root. Do\nnot use `packages/cli/bin.js` for local source-tree work; it is the packaged\nbuild entry and may use stale built output. Do not use `pnpm exec webstudio`,\n`pnpm --filter webstudio exec webstudio`, or a global `webstudio`: they can\nresolve an older binary.\n\nFor delegated design-system or “use every component” tasks, skip the generic warm-up sequence and start with exactly one MCP command: `webstudio workflow.next \'{"goal":"design-system-page"}\'`. Report that returned checkpoint to the parent/user and stop until continued.\n\n## Use MCP locally or optionally connect a client\n\nDo not install, register, or connect an MCP server merely because the user asks\nyou to edit a Webstudio project. If Webstudio MCP tools are already available,\nuse them. If you have shell access, use the local CLI shortcuts such as\n`webstudio meta.index` and `webstudio list-pages`; they expose the same project\noperations without changing client configuration or restarting the app.\n\nOnly when the user explicitly asks for persistent native MCP integration, run\nthe command for their client:\n\n- Claude Code: `webstudio connect claude`\n- Codex: `webstudio connect codex`\n- Cursor: `webstudio connect cursor`\n- VS Code or GitHub Copilot: `webstudio connect vscode`\n\nRun project operations from the linked project root. If the folder is not\nlinked, ask for an editable Builder share link and run\n`webstudio init --link <share-link> --json`. You can then use local CLI\nshortcuts immediately. Do not run `webstudio sync`, `webstudio connect`, or\nrestart the app unless the user specifically wants native MCP registration:\nMCP reads and edits the latest editable Builder build directly, including for\nprojects that have never been published. Treat the share link as a credential\nand do not include it in committed files, logs, screenshots, or issue reports.\n\nThe optional `connect` command verifies project access before changing client configuration. For\nClaude Code, Cursor, and VS Code it safely merges the `webstudio` server into\nthe client\'s project configuration. For Codex it runs both `codex mcp add` and\n`codex mcp get webstudio`; do not repeat those commands separately. Follow the\nreload, restart, or approval instruction printed by `connect`, then verify the\nloaded MCP connection by asking the client to use Webstudio MCP and list the\nproject pages. Use `--print` only to inspect the generated setup without\nchanging configuration or requiring project access.\n\n## Always\n\n1. webstudio permissions --json\n2. For bounded shell workflows, call MCP tools directly through the CLI shortcut, for example `webstudio meta.index` or `webstudio insert-fragment \'<json>\' --dry-run`. The explicit form `webstudio mcp single-op-call <tool> \'<json>\'` is equivalent and useful when you need to make the MCP boundary obvious. Use `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for multiple calls in one shared CLI session. Use a normal JSON file path for large batches. Use long-running `webstudio mcp` only when your environment is a real MCP client. Do not manually send raw JSON-RPC to `webstudio mcp` from a shell or PTY.\n3. Read MCP `meta.index`, for example `webstudio meta.index`.\n4. Use focused MCP calls with concrete JSON: `webstudio meta.guide \'{"brief":"Create a design system page using every component"}\'`, `webstudio meta.get-more-tools \'{"tools":["insert-fragment"]}\'`, `webstudio components.list \'{"source":"all"}\'`, `webstudio components.coverage-plan`, `webstudio components.search \'{"brief":"radix select"}\'`, `webstudio components.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`, `webstudio templates.list`, and `webstudio templates.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`.\n5. Read overview resources `webstudio://project/tools-overview` or `webstudio://project/components-overview` when useful. Read full resources `webstudio://project/tools` or `webstudio://project/components` only when focused tools are insufficient.\n6. Pick focused MCP read tool.\n7. Pick semantic MCP write tool.\n\nUse `webstudio schema mcp` for a compact MCP tool overview. Add `--verbose` only when exact input schemas for all tools are truly needed; otherwise prefer focused `meta.get-more-tools` and `components.*` calls.\n\nRun these commands from the linked project root. Use the MCP startup status line\'s absolute root for local files; write temporary scripts and artifacts under `<project root>/.temp`, not under a parent workspace.\n\nMonorepo quick path for a simple styled section:\n\n```sh\nnode packages/cli/local.js mcp single-op-call meta.index\nnode packages/cli/local.js mcp single-op-call m
Line truncated
|
||||
'# Webstudio CLI Manual for LLMs\n\nUse this order. Stop only when a command returns ok:false.\n\nIf you are inside the Webstudio monorepo, the first command discovery should use\nthe local CLI exactly as `node packages/cli/local.js ...` from the repo root. Do\nnot use `packages/cli/bin.js` for local source-tree work; it is the packaged\nbuild entry and may use stale built output. Do not use `pnpm exec webstudio`,\n`pnpm --filter webstudio exec webstudio`, or a global `webstudio`: they can\nresolve an older binary.\n\nFor delegated design-system or “use every component” tasks, skip the generic warm-up sequence and start with exactly one MCP command: `webstudio workflow.next \'{"goal":"design-system-page"}\'`. Report that returned checkpoint to the parent/user and stop until continued.\n\n## Use MCP locally or optionally connect a client\n\nDo not install, register, or connect an MCP server merely because the user asks\nyou to edit a Webstudio project. If Webstudio MCP tools are already available,\nuse them. If you have shell access, use the local CLI shortcuts such as\n`webstudio meta.index` and `webstudio list-pages`; they expose the same project\noperations without changing client configuration or restarting the app.\n\nOnly when the user explicitly asks for persistent native MCP integration, run\nthe command for their client:\n\n- Claude Code: `webstudio connect claude`\n- Codex: `webstudio connect codex`\n- Cursor: `webstudio connect cursor`\n- VS Code or GitHub Copilot: `webstudio connect vscode`\n\nRun project operations from the linked project root. If the folder is not\nlinked, ask for an editable Builder share link and run\n`webstudio init --link <share-link> --json`. You can then use local CLI\nshortcuts immediately. Do not run `webstudio sync`, `webstudio connect`, or\nrestart the app unless the user specifically wants native MCP registration:\nMCP reads and edits the latest editable Builder build directly, including for\nprojects that have never been published. Treat the share link as a credential\nand do not include it in committed files, logs, screenshots, or issue reports.\n\nThe optional `connect` command verifies project access before changing client configuration. For\nClaude Code, Cursor, and VS Code it safely merges the `webstudio` server into\nthe client\'s project configuration. For Codex it runs both `codex mcp add` and\n`codex mcp get webstudio`; do not repeat those commands separately. Follow the\nreload, restart, or approval instruction printed by `connect`, then verify the\nloaded MCP connection by asking the client to use Webstudio MCP and list the\nproject pages. Use `--print` only to inspect the generated setup without\nchanging configuration or requiring project access.\n\n## Always\n\n1. webstudio permissions --json\n2. For bounded shell workflows, call MCP tools directly through the CLI shortcut, for example `webstudio meta.index` or `webstudio insert-fragment \'<json>\' --dry-run`. The explicit form `webstudio mcp single-op-call <tool> \'<json>\'` is equivalent and useful when you need to make the MCP boundary obvious. Use `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for multiple calls in one shared CLI session. Use a normal JSON file path for large batches. Use long-running `webstudio mcp` only when your environment is a real MCP client. Do not manually send raw JSON-RPC to `webstudio mcp` from a shell or PTY.\n3. Read MCP `meta.index`, for example `webstudio meta.index`.\n4. Use focused MCP calls with concrete JSON: `webstudio meta.guide \'{"brief":"Create a design system page using every component"}\'`, `webstudio meta.get-more-tools \'{"tools":["insert-fragment"]}\'`, `webstudio components.list \'{"source":"all"}\'`, `webstudio components.coverage-plan`, `webstudio components.search \'{"brief":"radix select"}\'`, `webstudio components.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`, `webstudio templates.list`, and `webstudio templates.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'`.\n5. Read overview resources `webstudio://project/tools-overview` or `webstudio://project/components-overview` when useful. Read full resources `webstudio://project/tools` or `webstudio://project/components` only when focused tools are insufficient.\n6. Pick focused MCP read tool.\n7. Pick semantic MCP write tool.\n\nUse `webstudio schema mcp` for a compact MCP tool overview. Add `--verbose` only when exact input schemas for all tools are truly needed; otherwise prefer focused `meta.get-more-tools` and `components.*` calls.\n\nRun these commands from the linked project root. Use the MCP startup status line\'s absolute root for local files; write temporary scripts and artifacts under `<project root>/.temp`, not under a parent workspace.\n\nMonorepo quick path for a simple styled section:\n\n```sh\nnode packages/cli/local.js mcp single-op-call meta.index\nnode packages/cli/local.js mcp single-op-call m
Line truncated
|
||||
"manual-mcp":
|
||||
'# Webstudio MCP Manual\n\n`webstudio mcp` starts a stdio MCP server for real MCP clients. Shell users can call MCP tools with the shortcut form `webstudio <tool> \'<json>\'`, for example `webstudio meta.index` or `webstudio insert-fragment \'<json>\' --dry-run`. `webstudio mcp single-op-call` is the explicit equivalent and prints the structured JSON result. `webstudio mcp run` runs multiple MCP tool calls from inline JSON or a normal JSON file in one shared CLI session. Do not manually type or pipe raw JSON-RPC frames into `webstudio mcp` from an interactive shell or PTY.\n\n## Startup\n\nIf you are already working with a shell-capable agent, it can use the local CLI\ndirectly. Native MCP client registration is optional. Give the editable Builder\nshare link only when the trusted agent asks for it. Treat the share link as a\ncredential: do not include it in committed files, screenshots, logs, or issue\nreports.\n\n1. Configure a project with `webstudio init --link <api-share-link> --json`.\n2. Check capabilities with `webstudio permissions --json`.\n3. Use shortcut calls such as `webstudio meta.index` and `webstudio insert-fragment \'<json>\' --dry-run` for individual MCP tool calls. Use the explicit equivalent `webstudio mcp single-op-call <tool> \'<json>\'` when you need to force the MCP path, or `webstudio mcp run \'[{"tool":"components.find","input":{"brief":"button"}}]\'` for bounded multi-call workflows. Use `webstudio mcp run .temp/mcp-calls.json` for large batches.\n4. Start discovery with `meta.index`, then call focused tools with concrete JSON, for example `webstudio mcp single-op-call meta.guide \'{"brief":"Create a design system page using every component"}\'`.\n\nDo not run `webstudio sync`, install an MCP server, change client configuration,\nor restart the app for this local CLI workflow.\n\nWhen the user explicitly wants persistent native MCP integration, run\n`webstudio connect claude`, `webstudio connect codex`, `webstudio connect\ncursor`, or `webstudio connect vscode`. This optional command changes client\nconfiguration, so follow its client-specific reload or restart instruction.\nUse `--print` to inspect the generated setup without changing configuration or\nrequiring project access. For Codex, `connect` registers and verifies the server\nthrough the Codex CLI. Before changing client configuration, `connect` verifies\nthat the saved project endpoint is reachable and its credential is accepted.\n\nStart MCP from the linked Webstudio project root. The lifecycle status line prints that absolute root; create local scripts, screenshots, and temporary artifacts under that root, for example `<project root>/.temp/script.mjs`. If the shell starts in a parent workspace, `cd` into the project root first or use absolute paths.\n\nWhen developing inside the Webstudio monorepo, start the local CLI exactly as `node packages/cli/local.js mcp` from the repo root. Do not use `pnpm exec webstudio`, `pnpm --filter webstudio exec webstudio`, or a global `webstudio`: they can resolve an older binary.\n\nWhile the server is running, stdout is reserved for MCP JSON-RPC messages. Do not print human text from the server process. The server advertises MCP `logging` capability and emits sparse `notifications/message` logs for ready state and tool lifecycle checkpoints such as `tool preview.start started`, `tool preview.start still running after 10000ms`, and `tool preview.start succeeded in 1234ms`; stderr also mirrors these sparse lifecycle fallback lines prefixed with `[webstudio mcp]`.\n\n## One-Shot Tool Calls\n\nUse the shortcut `webstudio <tool> \'<json>\'` when you are operating from a shell and need one MCP tool result. The explicit form `webstudio mcp single-op-call <tool> \'<json>\'` is equivalent and avoids writing temporary Node.js stdio client scripts.\n\nExamples:\n\n```sh\nwebstudio mcp single-op-call meta.index\nwebstudio mcp single-op-call meta.guide \'{"brief":"Create a design system page using every component"}\'\nwebstudio mcp single-op-call meta.get-more-tools \'{"tools":["insert-fragment"]}\'\nwebstudio mcp single-op-call components.list \'{"source":"all"}\'\nwebstudio mcp single-op-call components.coverage-plan\nwebstudio mcp single-op-call components.search \'{"brief":"radix select"}\'\nwebstudio mcp single-op-call components.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'\nwebstudio mcp single-op-call templates.list\nwebstudio mcp single-op-call templates.get \'{"component":"@webstudio-is/sdk-components-react-radix:Select"}\'\nwebstudio mcp single-op-call insert-fragment --input-file .temp/insert-fragment.json\n```\n\nShortcut equivalents:\n\n```sh\nwebstudio meta.index\nwebstudio meta.guide \'{"brief":"Create a design system page using every component"}\'\nwebstudio meta.get-more-tools \'{"tools":["insert-fragment"]}\'\nwebstudio components.list \'{"source":"all"}\'\nwebstudio components.coverage-plan\nwebstudio components.search \'{"brief":"radix select"}\'Line truncated
|
||||
"mcp-startup-epilogue":
|
||||
@@ -43,7 +43,7 @@ export const cliDocSections = {
|
||||
"manual-llm": {
|
||||
implementationProcess: [
|
||||
'Discover capabilities with webstudio man --json, webstudio schema api, webstudio schema mcp, MCP meta.index, meta.guide, meta.get-more-tools, components.list, components.summary, components.coverage-plan, components.search, components.get, templates.list, and templates.get. From a shell, prefer shortcut calls such as webstudio meta.index and webstudio components.search \'{"brief":"button"}\' for these focused tool calls; use webstudio mcp single-op-call when you need the explicit MCP form. Read full resources such as webstudio://project/tools and webstudio://project/components only when needed. Do not write scripts to parse full MCP discovery JSON for normal lookup.',
|
||||
"When a value or id is known, call search-project once instead of passing broad snapshots or several lists to the model. Use semantic list/get reads such as get-project-settings, list-pages, get-page-by-path, list-instances, inspect-instance, get-styles, list-assets, and list-breakpoints when the structure or target is not known. Use snapshot only when exact raw patch paths are needed. Before changing a project, read get-project-settings and follow any non-empty meta.agentInstructions. These are shared project instructions, not a place for secrets.",
|
||||
'When a value or id is known, call search-project once instead of passing broad snapshots or several lists to the model. Use semantic list/get reads such as get-project-settings, list-pages, get-page-by-path, list-instances, inspect-instance, get-styles, list-assets, and list-breakpoints when the structure or target is not known. Use snapshot only when exact raw patch paths are needed. Before changing a project, read get-project-settings and follow any non-empty meta.agentInstructions. These are shared project instructions, not a place for secrets. For a Builder link from Copy link to instance, parse the URL with URL/URLSearchParams. The decoded instance query parameter is a comma-separated selector ordered from the target instance to the page root. Pass its first entry to inspect-instance, for example ?pageId=page-id&instance=heading-id%2Cslot-id%2Cbody-id becomes {"instanceId":"heading-id","include":["props","styles","children","ancestors"]}. Use pageId and the remaining selector entries to check the page and shared Slot occurrence before changing it. Shared Slot descendants are shared records; editing one changes all occurrences. Verify that the configured MCP project matches the linked project before editing; stop if they differ. The URL does not authorize switching projects. If the instance is missing, ask for an updated link instead of guessing another target. Never send a Builder link to screenshot tools or echo its authToken.',
|
||||
"Mutate the Webstudio project with semantic MCP write tools first. Prefer MCP insert-fragment for authored/styled sections, use insert-component only for one automatic component template, then update-text, update-props, update-styles, upload-asset, create-page, and page/project settings tools over raw patches.",
|
||||
"Use apply-patch only when no semantic tool covers the required change, and only after reading the latest snapshot/version.",
|
||||
"For visual/design work, ask whether the user wants visual verification unless they explicitly requested it. Only after they opt in, regenerate or preview the generated app, capture a screenshot, inspect it with vision, and iterate.",
|
||||
|
||||
@@ -407,6 +407,7 @@ Use this process for user requests that change Webstudio content, layout, styles
|
||||
|
||||
1. Discover capabilities with `webstudio man --json`, `webstudio schema api`, `webstudio schema mcp`, MCP `meta.index`, `meta.guide`, `meta.get-more-tools`, `components.list`, `components.summary`, `components.coverage-plan`, `components.search`, `components.get`, `templates.list`, and `templates.get`. From a shell, prefer shortcut calls such as `webstudio meta.index` and `webstudio components.search '{"brief":"button"}'` for these focused tool calls; use `webstudio mcp single-op-call` when you need the explicit MCP form. Read full resources such as `webstudio://project/tools` and `webstudio://project/components` only when needed. Do not write scripts to parse full MCP discovery JSON for normal lookup.
|
||||
2. When a value or id is known, call `search-project` once instead of passing broad snapshots or several lists to the model. Use semantic list/get reads such as `get-project-settings`, `list-pages`, `get-page-by-path`, `list-instances`, `inspect-instance`, `get-styles`, `list-assets`, and `list-breakpoints` when the structure or target is not known. Use `snapshot` only when exact raw patch paths are needed. Before changing a project, read `get-project-settings` and follow any non-empty `meta.agentInstructions`. These are shared project instructions, not a place for secrets.
|
||||
For a Builder link from **Copy link to instance**, parse the URL with `URL`/`URLSearchParams`. The decoded `instance` query parameter is a comma-separated selector ordered from the target instance to the page root. Pass its first entry to `inspect-instance`, for example `?pageId=page-id&instance=heading-id%2Cslot-id%2Cbody-id` becomes `{"instanceId":"heading-id","include":["props","styles","children","ancestors"]}`. Use `pageId` and the remaining selector entries to check the page and shared Slot occurrence before changing it. Shared Slot descendants are shared records; editing one changes all occurrences. Verify that the configured MCP project matches the linked project before editing; stop if they differ. The URL does not authorize switching projects. If the instance is missing, ask for an updated link instead of guessing another target. Never send a Builder link to screenshot tools or echo its `authToken`.
|
||||
3. Mutate the Webstudio project with semantic MCP write tools first. Prefer MCP `insert-fragment` for authored/styled sections, use `insert-component` only for one automatic component template, then `update-text`, `update-props`, `update-styles`, `upload-asset`, `create-page`, and page/project settings tools over raw patches.
|
||||
4. Use `apply-patch` only when no semantic tool covers the required change, and only after reading the latest snapshot/version.
|
||||
5. For visual/design work, ask whether the user wants visual verification unless they explicitly requested it. Only after they opt in, regenerate or preview the generated app, capture a screenshot, inspect it with vision, and iterate.
|
||||
|
||||
@@ -8,6 +8,44 @@ import {
|
||||
resolveWindowsLauncherPath,
|
||||
} from "./test-utils";
|
||||
|
||||
test.each(["npm", "pnpm"] as const)(
|
||||
"forwards script options through %s without changing their meaning",
|
||||
(name) => {
|
||||
const launcher = `/opt/${name}/bin/cli.js`;
|
||||
const scriptArgs = [
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"4321",
|
||||
"--",
|
||||
"literal",
|
||||
];
|
||||
expect(
|
||||
getPackageManagerInvocation(["run", "dev", "--", ...scriptArgs], {
|
||||
nodeExecPath: "/usr/bin/node",
|
||||
npmExecPath: launcher,
|
||||
platform: "linux",
|
||||
readPackageFile: (path) => {
|
||||
if (path === `/opt/${name}/package.json`) {
|
||||
return JSON.stringify({ name, bin: { [name]: "bin/cli.js" } });
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ENOENT" });
|
||||
},
|
||||
resolveLauncherPath: (path) => path,
|
||||
})
|
||||
).toEqual({
|
||||
command: "/usr/bin/node",
|
||||
args: [
|
||||
launcher,
|
||||
"run",
|
||||
"dev",
|
||||
...(name === "npm" ? ["--"] : []),
|
||||
...scriptArgs,
|
||||
],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
test("reuses the npm cli that launched webstudio for preview commands", () => {
|
||||
expect(
|
||||
getPackageManagerInvocation(["run", "build"], {
|
||||
|
||||
@@ -197,8 +197,13 @@ export const getPackageManagerInvocation = (
|
||||
options: PreviewPackageManagerOptions = defaultPreviewServerDependencies
|
||||
) => {
|
||||
const packageManager = resolvePreviewPackageManager(options);
|
||||
// npm consumes this separator; pnpm forwards it to the script instead.
|
||||
const commandArgs =
|
||||
packageManager.name === "pnpm" && args[0] === "run" && args[2] === "--"
|
||||
? [...args.slice(0, 2), ...args.slice(3)]
|
||||
: args;
|
||||
return {
|
||||
command: packageManager.command,
|
||||
args: [...packageManager.argsPrefix, ...args],
|
||||
args: [...packageManager.argsPrefix, ...commandArgs],
|
||||
};
|
||||
};
|
||||
@@ -3,9 +3,9 @@
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"build": "node ./node_modules/@react-router/dev/bin.js build",
|
||||
"dev": "node ./node_modules/@react-router/dev/bin.js dev",
|
||||
"start": "node ./node_modules/@react-router/serve/bin.js ./build/server/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,6 +5,8 @@ const sourceConditions =
|
||||
process.env.WEBSTUDIO_LOCAL_CLI_BOOTSTRAPPED === "1" ? ["webstudio"] : [];
|
||||
|
||||
export default defineConfig({
|
||||
// Local previews share node_modules, but must not share optimizer state.
|
||||
cacheDir: ".react-router/vite",
|
||||
plugins: [reactRouter()],
|
||||
server: {
|
||||
hmr: process.env.WEBSTUDIO_PREVIEW_HMR === "disabled" ? false : undefined,
|
||||
|
||||
Reference in new issue
Block a user