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
258 lines
7.8 KiB
TypeScript
258 lines
7.8 KiB
TypeScript
// Insert utilities own deciding where newly created or pasted instance content
|
|
// should be placed. Put insert target resolution and insertion commands here,
|
|
// while fragment cloning stays in fragment.ts and existing-instance moves stay
|
|
// in mutation.ts.
|
|
import { toast } from "@webstudio-is/design-system";
|
|
import { type WebstudioFragment, elementComponent } from "@webstudio-is/sdk";
|
|
import type { ConflictResolution } from "../token-conflict-dialog";
|
|
import {
|
|
$registeredComponentMetas,
|
|
$registeredTemplates,
|
|
$selectedInstanceSelector,
|
|
$selectedPage,
|
|
selectInstance,
|
|
} from "../nano-states";
|
|
import { $instances, $project, $props } from "../sync/data-stores";
|
|
import {
|
|
resolveComponentInsertTarget,
|
|
resolveFragmentInsertTarget,
|
|
type InsertTarget,
|
|
} from "@webstudio-is/project-build/runtime";
|
|
import { createComponentTemplateFragment } from "@webstudio-is/project-build/runtime";
|
|
import { builderRuntimeContext } from "@webstudio-is/project-build/runtime";
|
|
import { getInstanceLabel } from "~/builder/shared/instance-label";
|
|
import { executeRuntimeMutation } from "./data";
|
|
|
|
const getRootInstanceId = () => $selectedPage.get()?.rootInstanceId;
|
|
|
|
const onMissingTarget = () => {
|
|
toast.error("Cannot insert: the target no longer exists.");
|
|
};
|
|
|
|
const onRootTarget = () => {
|
|
toast.error(`Cannot insert into Global root`);
|
|
};
|
|
|
|
const onNoInsertMatch = (fragment: Pick<WebstudioFragment, "instances">) => {
|
|
return (message: string) => {
|
|
const component = fragment.instances[0].component;
|
|
const label = getInstanceLabel({ component });
|
|
toast.warn(message || `"${label}" has no place here`);
|
|
};
|
|
};
|
|
|
|
const onNoComponentInsertMatch = (component: string) => {
|
|
return (message: string) => {
|
|
const label = getInstanceLabel({ component });
|
|
toast.warn(message || `"${label}" has no place here`);
|
|
};
|
|
};
|
|
|
|
const getFragmentInsertTarget = (
|
|
fragment: WebstudioFragment,
|
|
from?: Insertable
|
|
) => {
|
|
const rootInstanceId = getRootInstanceId();
|
|
if (rootInstanceId === undefined) {
|
|
return;
|
|
}
|
|
const instances = $instances.get();
|
|
return resolveFragmentInsertTarget({
|
|
fragment,
|
|
instances,
|
|
props: $props.get(),
|
|
metas: $registeredComponentMetas.get(),
|
|
rootInstanceId,
|
|
selectedInstanceSelector: $selectedInstanceSelector.get(),
|
|
from,
|
|
onRootTarget,
|
|
onMissingTarget,
|
|
onNoMatch: onNoInsertMatch(fragment),
|
|
});
|
|
};
|
|
|
|
export const insertWebstudioElementAt = (insertable?: Insertable) => {
|
|
const target = getComponentInsertTarget(elementComponent, insertable);
|
|
if (target === undefined) {
|
|
return false;
|
|
}
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.insertComponent",
|
|
input: {
|
|
parentInstanceId: target.parentInstanceId,
|
|
component: elementComponent,
|
|
tag: target.tag,
|
|
insertIndex: target.insertIndex,
|
|
},
|
|
});
|
|
const newInstanceId = result?.result.rootInstanceIds[0];
|
|
if (newInstanceId === undefined) {
|
|
return false;
|
|
}
|
|
const parentSelector =
|
|
result?.result.parentInstanceId === undefined ||
|
|
result.result.parentInstanceId === target.parentInstanceId
|
|
? target.parentSelector
|
|
: [result.result.parentInstanceId, ...target.parentSelector];
|
|
selectInstance([newInstanceId, ...parentSelector]);
|
|
return true;
|
|
};
|
|
|
|
const getComponentInsertTarget = (component: string, from?: Insertable) => {
|
|
const rootInstanceId = getRootInstanceId();
|
|
if (rootInstanceId === undefined) {
|
|
return;
|
|
}
|
|
return resolveComponentInsertTarget({
|
|
component,
|
|
templates: $registeredTemplates.get(),
|
|
context: builderRuntimeContext,
|
|
instances: $instances.get(),
|
|
props: $props.get(),
|
|
metas: $registeredComponentMetas.get(),
|
|
rootInstanceId,
|
|
selectedInstanceSelector: $selectedInstanceSelector.get(),
|
|
from,
|
|
onRootTarget,
|
|
onMissingTarget,
|
|
onNoMatch: onNoComponentInsertMatch(component),
|
|
});
|
|
};
|
|
|
|
export const insertWebstudioComponentAt = (
|
|
component: string,
|
|
insertable?: Insertable
|
|
) => {
|
|
const target = getComponentInsertTarget(component, insertable);
|
|
if (target === undefined) {
|
|
return false;
|
|
}
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.insertComponent",
|
|
input: {
|
|
parentInstanceId: target.parentInstanceId,
|
|
component,
|
|
tag: target.tag,
|
|
insertIndex: target.insertIndex,
|
|
},
|
|
});
|
|
const newInstanceId = result?.result.rootInstanceIds[0];
|
|
if (result !== undefined && newInstanceId !== undefined) {
|
|
const parentSelector =
|
|
result.result.parentInstanceId === target.parentInstanceId
|
|
? target.parentSelector
|
|
: [result.result.parentInstanceId, ...target.parentSelector];
|
|
selectInstance([newInstanceId, ...parentSelector]);
|
|
}
|
|
if (result?.result.didMergeBreakpointsDueToLimit === true) {
|
|
toast.info(
|
|
"Some breakpoints were merged because the project reached the breakpoint limit."
|
|
);
|
|
}
|
|
return result !== undefined;
|
|
};
|
|
|
|
export const insertWebstudioFragmentAt = (
|
|
fragment: WebstudioFragment,
|
|
insertable?: Insertable,
|
|
conflictResolution?: ConflictResolution,
|
|
options?: { contentMode?: boolean; onBreakpointLimitMerge?: () => void }
|
|
): boolean => {
|
|
const hasChildren = fragment.children.length > 0;
|
|
const hasTokens = fragment.styleSources.length > 0;
|
|
if (!hasChildren && !hasTokens) {
|
|
return false;
|
|
}
|
|
// Tokens-only fragment: insert tokens/breakpoints/styles without instances
|
|
if (!hasChildren && hasTokens) {
|
|
const projectId = $project.get()?.id;
|
|
if (projectId === undefined) {
|
|
return false;
|
|
}
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.insertFragment",
|
|
input: {
|
|
fragment,
|
|
conflictResolution,
|
|
contentMode: options?.contentMode,
|
|
},
|
|
});
|
|
if (result?.result.didMergeBreakpointsDueToLimit === true) {
|
|
options?.onBreakpointLimitMerge?.();
|
|
}
|
|
return result !== undefined;
|
|
}
|
|
const target = getFragmentInsertTarget(fragment, insertable);
|
|
if ($project.get() === undefined || target === undefined) {
|
|
return false;
|
|
}
|
|
const result = executeRuntimeMutation({
|
|
id: "instances.insertFragment",
|
|
input: {
|
|
parentInstanceId: target.parentInstanceId,
|
|
fragment,
|
|
conflictResolution,
|
|
contentMode: options?.contentMode,
|
|
insertIndex: target.insertIndex,
|
|
},
|
|
});
|
|
const newInstanceId = result?.result.rootInstanceIds[0];
|
|
if (result !== undefined && newInstanceId !== undefined) {
|
|
const resultParentInstanceId =
|
|
"parentInstanceId" in result.result
|
|
? result.result.parentInstanceId
|
|
: undefined;
|
|
const nextParentSelector =
|
|
resultParentInstanceId === undefined ||
|
|
resultParentInstanceId === target.parentInstanceId
|
|
? target.parentSelector
|
|
: [resultParentInstanceId, ...target.parentSelector];
|
|
selectInstance([newInstanceId, ...nextParentSelector]);
|
|
}
|
|
if (result?.result.didMergeBreakpointsDueToLimit === true) {
|
|
options?.onBreakpointLimitMerge?.();
|
|
}
|
|
return result !== undefined;
|
|
};
|
|
|
|
export const getComponentTemplateData = (
|
|
componentOrTemplate: string
|
|
): WebstudioFragment => {
|
|
return createComponentTemplateFragment({
|
|
component: componentOrTemplate,
|
|
templates: $registeredTemplates.get(),
|
|
createId: builderRuntimeContext.createId,
|
|
});
|
|
};
|
|
|
|
export type Insertable = InsertTarget;
|
|
|
|
export const findClosestInsertable = (
|
|
fragment: WebstudioFragment,
|
|
from?: Insertable
|
|
): undefined | Insertable => {
|
|
const rootInstanceId = getRootInstanceId();
|
|
if (rootInstanceId === undefined) {
|
|
return;
|
|
}
|
|
const target = resolveFragmentInsertTarget({
|
|
fragment,
|
|
instances: $instances.get(),
|
|
props: $props.get(),
|
|
metas: $registeredComponentMetas.get(),
|
|
rootInstanceId,
|
|
selectedInstanceSelector: $selectedInstanceSelector.get(),
|
|
from,
|
|
onRootTarget,
|
|
onMissingTarget,
|
|
onNoMatch: onNoInsertMatch(fragment),
|
|
});
|
|
if (target === undefined) {
|
|
return;
|
|
}
|
|
return {
|
|
parentSelector: target.parentSelector,
|
|
position: target.insertIndex ?? "end",
|
|
};
|
|
};
|