Files
Oleg Isonen 4ea8928880 fix: preserve legacy content through validation (#5933)
## Summary

- Keep the shared content-model validator strict during normal Builder
authoring.
- Report HTML and component nesting violations as publish warnings
instead of blocking legacy projects.
- Allow paste operations to retain violations already contained inside
legacy fragments and surface them as warnings.
- Continue rejecting new violations introduced at the paste destination
boundary.
- Remove obsolete empty content models from CodeText and Video so their
runtime-supported, HTML-valid children pass validation.

## Root cause

The new pre-publish audit applied current authoring constraints to
historical project trees. Some legacy structures, including old
Accordion triggers containing an h3 inside a button, were created by
earlier Webstudio templates and cannot satisfy the current HTML model
without migration. Blocking publish or paste would make untouched legacy
content unusable.

CodeText and Video also had metadata that rejected children their
renderers intentionally support. Those verified metadata mismatches are
corrected independently of the compatibility policy.

## Behavior

- New invalid nesting remains prevented by normal authoring operations.
- Existing invalid nesting produces a Publish warning and does not abort
publishing.
- Webstudio instance, HTML, JSX, and Webflow paste retain
fragment-internal violations and show a paste warning.
- Paste still validates the destination and cannot introduce a new
invalid parent-child relationship.
- Resource integrity and missing audit inputs still prevent publishing.

## Verification

- [x] Legacy button containing h3 reports a publish warning
- [x] Legacy invalid subtree pastes successfully with a warning
- [x] New paste destination violations remain rejected or use the
closest valid target
- [x] Valid legacy CodeText children pass validation
- [x] Valid Video source and track children pass validation
- [x] Project-build matcher and insert-target tests
- [x] Builder instance, HTML, JSX, and Webflow paste tests
- [x] Project-build and Builder typechecks
- [x] Focused oxlint and formatting checks
2026-07-21 11:31:38 +02:00

221 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, undefined, {
allowContentModelWarnings: true,
});
if (insertable === undefined) {
return pasteHandled;
}
await insertFragmentWithBreakpointWarning(fragment, insertable);
return pasteHandled;
};
export const webflow: Plugin = {
name: "webflow",
mimeType: "application/json",
onPaste: handlePasteWebflow,
};
export const __testing__ = {
toWebstudioFragment,
};