mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-04 11:09:07 +09:00
## Summary This improves how agents discover, author, inspect, and verify Webstudio projects while reducing unnecessary MCP context usage. ## What changed - Adds an **Agents** section to Project Settings with ready-to-run setup commands and client configuration for Claude Code, Codex, Cursor, and VS Code. - Makes large list and schema responses compact and paginated by default, with **verbose** available when complete records or diagnostics are needed. - Applies consistent compact output to assets, instances, styles, design tokens, CLI schemas, and project-session metadata. - Returns actionable expression warnings for props, resources, and Collection bindings, including unknown variables and risky render-time mutation resources. - Improves Collection expression guidance so agents can bind array items with the correct scoped variables. - Reports rendered audit outcomes explicitly as complete, partial, confirmation-required, or failed. - Expands runtime output schemas so MCP clients can validate responses reliably. - Strengthens the packaged CLI smoke workflow with a realistic seven-page stdio MCP scenario. ## Architecture - Consolidates `project-build` from 65 file-oriented entrypoints to 11 architecture-owned surfaces. - Separates the core model, contracts, runtime authoring, transfer, state, project sessions, MCP, visual tooling, persistence codecs, and authenticated server services. - Isolates Node-only JSX evaluation from browser-safe transfer utilities and authenticated server modules. - Keeps persistence codecs independent from authenticated database services, preventing unrelated module initialization. - Defines shared instance-path types once and reuses them across runtime modules. - Preserves Immer initialization through explicit package side-effect metadata. - Adds package tests that prevent a return to source-file entrypoints or generic exposure of Node-only surfaces. ## Validation - Full repository checks, tests, typechecks, generated-file checks, and fixtures - All 163 Builder test files and 2,141 tests - All 71 project-build test files and 1,794 tests - Lint and package-boundary validation - Full packed CLI release smoke test, including install, preview, agent setup, seven-page stdio MCP workflow, rendered audit, and structured commands
219 lines
6.0 KiB
TypeScript
219 lines
6.0 KiB
TypeScript
import type { Instance, WebstudioFragment } from "@webstudio-is/sdk";
|
|
import { findClosestInsertable } from "../../instance-utils/insert";
|
|
import { $project, $styleSources } from "~/shared/sync/data-stores";
|
|
import {
|
|
type WfData,
|
|
wfData,
|
|
wfNodeTypes,
|
|
type WfNode,
|
|
type WfStyle,
|
|
type WfAsset,
|
|
} from "@webstudio-is/project-build/transfer";
|
|
import { addInstanceAndProperties } from "@webstudio-is/project-build/transfer";
|
|
import { addStyles } from "@webstudio-is/project-build/transfer";
|
|
import { builderApi } from "~/shared/builder-api";
|
|
import { denormalizeSrcProps } from "../asset-upload";
|
|
import { nanoHash } from "~/shared/nano-hash";
|
|
import { pasteHandled, pasteIgnored, type Plugin } from "../copy-paste";
|
|
import { builderRuntimeContext } from "@webstudio-is/project-build/runtime";
|
|
import {
|
|
hasFragmentData,
|
|
insertFragmentWithBreakpointWarning,
|
|
} from "../fragment-utils";
|
|
|
|
const { toast } = builderApi;
|
|
|
|
const toWebstudioFragment = async (
|
|
wfData: WfData,
|
|
createId = builderRuntimeContext.createId
|
|
) => {
|
|
const fragment: WebstudioFragment = {
|
|
children: [],
|
|
instances: [],
|
|
props: [],
|
|
breakpoints: [],
|
|
styles: [],
|
|
styleSources: [],
|
|
styleSourceSelections: [],
|
|
dataSources: [],
|
|
resources: [],
|
|
assets: [],
|
|
};
|
|
|
|
const wfNodes = new Map<WfNode["_id"], WfNode>();
|
|
for (const node of wfData.payload.nodes) {
|
|
if ("type" in node || "text" in node) {
|
|
wfNodes.set(node._id, node);
|
|
}
|
|
}
|
|
const wfStyles = new Map<WfStyle["_id"], WfStyle>(
|
|
wfData.payload.styles.map((style: WfStyle) => [style._id, style])
|
|
);
|
|
|
|
const wfAssets = new Map<WfAsset["_id"], WfAsset>(
|
|
wfData.payload.assets.map((asset: WfAsset) => [asset._id, asset])
|
|
);
|
|
|
|
// False value used to skip a node.
|
|
const doneNodes = new Map<WfNode["_id"], Instance["id"] | false>();
|
|
for (const wfNode of wfNodes.values()) {
|
|
addInstanceAndProperties(wfNode, doneNodes, wfNodes, fragment, createId);
|
|
}
|
|
|
|
/**
|
|
* Generates deterministic style IDs based on sourceId or unique data.
|
|
* This simplifies merging and deduplicating styles from different sources.
|
|
*/
|
|
const generateStyleSourceId = async (sourceData: string) => {
|
|
// We are using projectId here to avoid id collisions between different projects.
|
|
const projectId = $project.get()?.id;
|
|
if (projectId === undefined) {
|
|
throw new Error("Project id is not set");
|
|
}
|
|
return nanoHash(`${projectId}-${sourceData}`);
|
|
};
|
|
|
|
await addStyles({
|
|
wfNodes,
|
|
wfStyles,
|
|
wfAssets,
|
|
doneNodes,
|
|
fragment,
|
|
generateStyleSourceId,
|
|
createId,
|
|
existingStyleSourceIds: new Set($styleSources.get().keys()),
|
|
onInvalidStyleValue: (message) => {
|
|
toast.error(message);
|
|
},
|
|
});
|
|
// First node should be always the root node in theory, if not
|
|
// we need to find a node that is not a child of any other node.
|
|
const rootWfNode = wfData.payload.nodes[0];
|
|
const rootInstanceId = doneNodes.get(rootWfNode._id);
|
|
if (rootInstanceId === false) {
|
|
return fragment;
|
|
}
|
|
if (rootInstanceId === undefined) {
|
|
console.error(`No root instance id found for node ${rootWfNode._id}`);
|
|
return fragment;
|
|
}
|
|
fragment.children = [
|
|
{
|
|
type: "id",
|
|
value: rootInstanceId,
|
|
},
|
|
];
|
|
return fragment;
|
|
};
|
|
|
|
type WebflowParseResult =
|
|
| { owned: false }
|
|
| { owned: true; success: false; error: string }
|
|
| { owned: true; success: true; data: WfData };
|
|
|
|
const parse = (clipboardData: string): WebflowParseResult => {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(clipboardData);
|
|
} catch {
|
|
return { owned: false };
|
|
}
|
|
|
|
if (data.type !== "@webflow/XscpData") {
|
|
return { owned: false };
|
|
}
|
|
|
|
const payloadNodes = Array.isArray(data.payload?.nodes)
|
|
? data.payload.nodes
|
|
: [];
|
|
const unsupportedNodeTypes: Set<string> = new Set(
|
|
payloadNodes
|
|
.filter((node: { type: string }) => {
|
|
return (
|
|
node.type !== undefined &&
|
|
wfNodeTypes.includes(node.type as (typeof wfNodeTypes)[number]) ===
|
|
false
|
|
);
|
|
})
|
|
.map((node: { type: string }) => node.type)
|
|
);
|
|
|
|
if (unsupportedNodeTypes.size !== 0) {
|
|
const message = `Skipping unsupported elements: ${[...unsupportedNodeTypes.values()].join(", ")}`;
|
|
toast.info(message);
|
|
console.info(message);
|
|
}
|
|
|
|
const result = wfData.safeParse(data);
|
|
|
|
if (result.success) {
|
|
const unparsedTypes = new Set<string>();
|
|
|
|
for (let i = 0; i !== result.data.payload.nodes.length; ++i) {
|
|
if ("type" in result.data.payload.nodes[i]) {
|
|
continue;
|
|
}
|
|
|
|
if (data.payload.nodes[i].type === undefined) {
|
|
continue;
|
|
}
|
|
|
|
const probablyUnparsedType = data.payload.nodes[i].type;
|
|
|
|
if (unsupportedNodeTypes.has(probablyUnparsedType)) {
|
|
continue;
|
|
}
|
|
|
|
unparsedTypes.add(probablyUnparsedType);
|
|
}
|
|
|
|
if (unparsedTypes.size !== 0) {
|
|
const message = `The following types were skipped due to a parsing error: ${[...unparsedTypes.values()].join(", ")}`;
|
|
toast.info(message);
|
|
console.info(message);
|
|
}
|
|
|
|
return { owned: true, success: true, data: result.data };
|
|
}
|
|
|
|
return { owned: true, success: false, error: result.error.message };
|
|
};
|
|
|
|
const handlePasteWebflow = async (clipboardData: string) => {
|
|
const project = $project.get();
|
|
const result = parse(clipboardData);
|
|
if (result.owned === false) {
|
|
return pasteIgnored;
|
|
}
|
|
if (result.success === false) {
|
|
return { success: false, error: result.error } as const;
|
|
}
|
|
if (project === undefined) {
|
|
return pasteHandled;
|
|
}
|
|
|
|
let fragment = await toWebstudioFragment(result.data);
|
|
if (hasFragmentData(fragment) === false) {
|
|
return pasteHandled;
|
|
}
|
|
fragment = await denormalizeSrcProps(fragment);
|
|
|
|
const insertable = findClosestInsertable(fragment);
|
|
if (insertable === undefined) {
|
|
return pasteHandled;
|
|
}
|
|
|
|
insertFragmentWithBreakpointWarning(fragment, insertable);
|
|
return pasteHandled;
|
|
};
|
|
|
|
export const webflow: Plugin = {
|
|
name: "webflow",
|
|
mimeType: "application/json",
|
|
onPaste: handlePasteWebflow,
|
|
};
|
|
|
|
export const __testing__ = {
|
|
toWebstudioFragment,
|
|
};
|