From 49915536cff6f488082b74f9569b41d346380f8b Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Fri, 17 Jul 2026 22:41:08 +0100 Subject: [PATCH 1/5] build: Release 17-07-2026 From 50126db65d415485e488e331933d5332f07732c6 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Fri, 17 Jul 2026 22:41:27 +0100 Subject: [PATCH 2/5] ::migrate:: From 28d3b0035cb0f9ad318f20490a8d0415bb4a3ae2 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Tue, 21 Jul 2026 10:31:38 +0100 Subject: [PATCH 3/5] 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 --- .../command-panel/groups/wrap-group.test.tsx | 13 ++-- .../app/builder/features/publish/publish.tsx | 40 ++++++++++-- .../app/shared/copy-paste/fragment-utils.ts | 24 ++++++- .../shared/copy-paste/plugin-instance.test.ts | 35 ++++++++++ .../app/shared/copy-paste/plugin-instance.ts | 12 +++- .../plugin-webflow/plugin-webflow.ts | 4 +- .../app/shared/instance-utils/insert.ts | 28 ++++++-- .../src/runtime/insert-target.test.tsx | 21 ++++++ .../src/runtime/insert-target.ts | 30 +++++---- .../src/runtime/matcher.test.tsx | 65 ++++++++++++++++++- packages/project-build/src/runtime/matcher.ts | 58 ++++++++++++++++- .../src/runtime/pre-publish-audit.test.tsx | 43 +++++++++--- .../src/runtime/pre-publish-audit.ts | 8 ++- .../sdk-components-react/src/code-text.ws.ts | 4 -- packages/sdk-components-react/src/video.ws.ts | 4 -- 15 files changed, 334 insertions(+), 55 deletions(-) diff --git a/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx b/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx index ade504c49d35..8e62e639b9dd 100644 --- a/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx +++ b/apps/builder/app/builder/features/command-panel/groups/wrap-group.test.tsx @@ -94,20 +94,19 @@ describe("canWrapInstance for components", () => { expect(result).toBe(true); }); - test("should reject invalid wrapping (text in CodeText)", () => { + test("should allow wrapping text in a legacy CodeText", () => { $instances.set( renderData( <$.Body ws:id="body"> - <$.Box ws:id="box"> + <$.Text ws:id="text">Hello ).instances ); - selectInstance(["box", "body"]); + selectInstance(["text", "body"]); - // CodeText only accepts text content, not boxes const result = canWrapInstance( - "box", - ["box", "body"], + "text", + ["text", "body"], "body", "CodeText", undefined, @@ -115,7 +114,7 @@ describe("canWrapInstance for components", () => { $props.get(), $registeredComponentMetas.get() ); - expect(result).toBe(false); + expect(result).toBe(true); }); }); diff --git a/apps/builder/app/builder/features/publish/publish.tsx b/apps/builder/app/builder/features/publish/publish.tsx index 35712e952dc8..540e34f92109 100644 --- a/apps/builder/app/builder/features/publish/publish.tsx +++ b/apps/builder/app/builder/features/publish/publish.tsx @@ -103,7 +103,7 @@ import { type PrePublishAuditFinding, } from "@webstudio-is/project-build/runtime"; -const PrePublishAuditError = ({ +const PrePublishAuditMessage = ({ finding, }: { finding: PrePublishAuditFinding; @@ -164,7 +164,7 @@ const PrePublishAuditError = ({ ); }; -const getPrePublishAuditError = () => { +const getPrePublishAuditMessages = () => { const findings = runPrePublishAudit({ pages: $pages.get(), instances: $instances.get(), @@ -173,8 +173,14 @@ const getPrePublishAuditError = () => { resources: $resources.get(), metas: $registeredComponentMetas.get(), }); - const finding = findings.find(({ severity }) => severity === "error"); - return finding && ; + const getMessage = (severity: PrePublishAuditFinding["severity"]) => { + const finding = findings.find((item) => item.severity === severity); + return finding && ; + }; + return { + error: getMessage("error"), + warning: getMessage("warning"), + }; }; type ChangeProjectDomainProps = { @@ -463,6 +469,9 @@ const Publish = ({ const [publishError, setPublishError] = useState< undefined | JSX.Element | string >(); + const [publishWarning, setPublishWarning] = useState< + undefined | JSX.Element | string + >(); const [isPublishing, setIsPublishing] = useOptimistic(false); const buttonRef = useRef(null); const [hasSelectedDomains, setHasSelectedDomains] = useState(false); @@ -513,13 +522,19 @@ const Publish = ({ const handlePublish = async (formData: FormData) => { setPublishError(undefined); + setPublishWarning(undefined); - const auditError = getPrePublishAuditError(); + const { error: auditError, warning: auditWarning } = + getPrePublishAuditMessages(); if (auditError !== undefined) { toast.error(auditError); setPublishError(auditError); return; } + if (auditWarning !== undefined) { + toast.warn(auditWarning); + setPublishWarning(auditWarning); + } // Custom domain checkboxes are disabled on free plan so they are never // submitted — only the staging (wstd.io) domain can appear in formData. @@ -639,6 +654,9 @@ const Publish = ({ return ( {publishError && {publishError}} + {publishWarning && ( + {publishWarning} + )} (); + const [publishWarning, setPublishWarning] = useState(); if (project == null) { throw new Error("Project not found"); @@ -729,6 +748,9 @@ const PublishStatic = ({ return ( {publishError && {publishError}} + {publishWarning && ( + {publishWarning} + )} {status === "FAILED" && {statusText}} { try { setPublishError(undefined); - const auditError = getPrePublishAuditError(); + setPublishWarning(undefined); + const { error: auditError, warning: auditWarning } = + getPrePublishAuditMessages(); if (auditError !== undefined) { toast.error(auditError); setPublishError(auditError); return; } + if (auditWarning !== undefined) { + toast.warn(auditWarning); + setPublishWarning(auditWarning); + } setIsPendingOptimistic(true); diff --git a/apps/builder/app/shared/copy-paste/fragment-utils.ts b/apps/builder/app/shared/copy-paste/fragment-utils.ts index 152e02045b59..c41291679ee6 100644 --- a/apps/builder/app/shared/copy-paste/fragment-utils.ts +++ b/apps/builder/app/shared/copy-paste/fragment-utils.ts @@ -1,5 +1,8 @@ import type { WebstudioFragment } from "@webstudio-is/sdk"; -import { breakpointPasteLimitWarning } from "@webstudio-is/project-build/runtime"; +import { + breakpointPasteLimitWarning, + type FragmentContentModelWarning, +} from "@webstudio-is/project-build/runtime"; import { insertWebstudioFragmentAt, type Insertable, @@ -10,6 +13,23 @@ import { resolveFragmentTokenConflicts } from "../resolve-token-conflicts"; export const hasFragmentData = (fragment: WebstudioFragment) => fragment.children.length > 0 || fragment.styleSources.length > 0; +export const reportFragmentContentModelWarnings = ( + warnings: FragmentContentModelWarning[] +) => { + const [firstWarning] = warnings; + if (firstWarning === undefined) { + return; + } + const remainingCount = warnings.length - 1; + const suffix = + remainingCount === 0 + ? "" + : ` (${remainingCount} more validation ${remainingCount === 1 ? "warning" : "warnings"})`; + builderApi.toast.warn( + `Pasted with warning: ${firstWarning.message}${suffix}` + ); +}; + export const insertFragmentWithBreakpointWarning = async ( fragment: WebstudioFragment, insertable?: Insertable @@ -19,6 +39,8 @@ export const insertFragmentWithBreakpointWarning = async ( return false; } return insertWebstudioFragmentAt(fragment, insertable, conflictResolution, { + allowContentModelWarnings: true, + onContentModelWarnings: reportFragmentContentModelWarnings, onBreakpointLimitMerge: () => { builderApi.toast.warn(breakpointPasteLimitWarning); }, diff --git a/apps/builder/app/shared/copy-paste/plugin-instance.test.ts b/apps/builder/app/shared/copy-paste/plugin-instance.test.ts index e566a4263e62..f797b1b96902 100644 --- a/apps/builder/app/shared/copy-paste/plugin-instance.test.ts +++ b/apps/builder/app/shared/copy-paste/plugin-instance.test.ts @@ -43,6 +43,7 @@ import { expectSlotsShareFragment, } from "../slot-test-utils"; import { pasteHandled } from "./copy-paste"; +import { initBuilderApi } from "../builder-api"; const expectString = expect.any(String) as unknown as string; @@ -204,6 +205,40 @@ describe("copy and cut guards", () => { ]); }); + test("pastes a legacy invalid subtree with a warning", async () => { + $instances.set( + toMap([ + createInstance("body0", "Body", [ + { type: "id", value: "legacy-button" }, + ]), + { + ...createInstance("legacy-button", elementComponent, [ + { type: "id", value: "legacy-heading" }, + ]), + tag: "button", + }, + { + ...createInstance("legacy-heading", elementComponent, []), + tag: "h3", + }, + ] satisfies Instance[]) + ); + selectInstance(["legacy-button", "body0"]); + const clipboardData = instanceText.onCopy?.() ?? ""; + selectInstance(["body0"]); + initBuilderApi(); + const previousWarn = window.__webstudio__$__builderApi.toast.warn; + const warn = vi.fn(); + window.__webstudio__$__builderApi.toast.warn = warn; + + expect(await instanceText.onPaste?.(clipboardData)).toEqual(pasteHandled); + expect($instances.get().get("body0")?.children).toHaveLength(2); + expect(warn).toHaveBeenCalledWith( + "Pasted with warning: Placing

element inside a