diff --git a/apps/builder/app/shared/content-model.test.tsx b/apps/builder/app/shared/content-model.test.tsx
index 66f81dbd016b..6609400ef118 100644
--- a/apps/builder/app/shared/content-model.test.tsx
+++ b/apps/builder/app/shared/content-model.test.tsx
@@ -1,4 +1,4 @@
-import { expect, test } from "vitest";
+import { describe, expect, test } from "vitest";
import { coreMetas } from "@webstudio-is/sdk";
import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
import { $, renderData, ws } from "@webstudio-is/template";
@@ -482,3 +482,129 @@ test("edge case: support a > img", () => {
})
).toBeTruthy();
});
+
+describe("component content model", () => {
+ test("restrict children with specific component", () => {
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.HtmlEmbed>
+
+ $.HtmlEmbed>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["bodyId"],
+ })
+ ).toBeTruthy();
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.HtmlEmbed>
+
+ $.HtmlEmbed>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["bodyId"],
+ })
+ ).toBeFalsy();
+ });
+
+ test("restrict components within specific ancestor", () => {
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.Vimeo>
+ <$.VimeoSpinner>$.VimeoSpinner>
+ $.Vimeo>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["bodyId"],
+ })
+ ).toBeTruthy();
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.Vimeo>
+
+ <$.VimeoSpinner>$.VimeoSpinner>
+
+ $.Vimeo>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["bodyId"],
+ })
+ ).toBeTruthy();
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.VimeoSpinner>$.VimeoSpinner>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["bodyId"],
+ })
+ ).toBeFalsy();
+ });
+
+ test("prevent self nesting with descendants restriction", () => {
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.Vimeo>
+ <$.VimeoSpinner>
+ <$.VimeoSpinner>$.VimeoSpinner>
+ $.VimeoSpinner>
+ $.Vimeo>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["bodyId"],
+ })
+ ).toBeFalsy();
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.Vimeo>
+ <$.VimeoSpinner>
+ <$.Vimeo>
+ <$.VimeoSpinner>$.VimeoSpinner>
+ $.Vimeo>
+ $.VimeoSpinner>
+ $.Vimeo>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["bodyId"],
+ })
+ ).toBeTruthy();
+ });
+
+ test("pass constraints when check deep in the tree", () => {
+ expect(
+ isTreeSatisfyingContentModel({
+ ...renderData(
+
+ <$.Vimeo ws:id="vimeoId">
+
+ <$.VimeoSpinner>$.VimeoSpinner>
+
+ $.Vimeo>
+
+ ),
+ metas: defaultMetas,
+ instanceSelector: ["divId", "vimeoId", "bodyId"],
+ })
+ ).toBeTruthy();
+ });
+});
diff --git a/apps/builder/app/shared/content-model.ts b/apps/builder/app/shared/content-model.ts
index 0480af3332d7..ab286b95b998 100644
--- a/apps/builder/app/shared/content-model.ts
+++ b/apps/builder/app/shared/content-model.ts
@@ -2,11 +2,13 @@ import {
categoriesByTag,
childrenCategoriesByTag,
} from "@webstudio-is/html-data";
-import type {
- Instance,
- Instances,
- Props,
- WsComponentMeta,
+import {
+ parseComponentName,
+ type ContentModel,
+ type Instance,
+ type Instances,
+ type Props,
+ type WsComponentMeta,
} from "@webstudio-is/sdk";
import type { InstanceSelector } from "./tree-utils";
@@ -171,6 +173,83 @@ const computeAllowedCategories = ({
return allowedCategories;
};
+const defaultComponentContentModel: ContentModel = {
+ category: "instance",
+ children: ["rich-text", "instance", "transparent"],
+};
+
+const isComponentSatisfyingContentModel = ({
+ metas,
+ component,
+ allowedCategories,
+}: {
+ metas: Map;
+ component: string;
+ allowedCategories: undefined | string[];
+}) => {
+ const contentModel = {
+ ...defaultComponentContentModel,
+ ...metas.get(component)?.contentModel,
+ };
+ return (
+ // body does not have parent
+ allowedCategories === undefined ||
+ // parents may restrict specific components with none category
+ // any instances
+ // or nothing
+ allowedCategories.includes(component) ||
+ allowedCategories.includes(contentModel.category)
+ );
+};
+
+const getComponentChildrenCategories = ({
+ metas,
+ component,
+ allowedCategories,
+}: {
+ metas: Map;
+ component: string;
+ allowedCategories: undefined | string[];
+}) => {
+ const contentModel =
+ metas.get(component)?.contentModel ?? defaultComponentContentModel;
+ let childrenCategories = contentModel.children;
+ // transparent categories makes component inherit constraints from parent
+ if (childrenCategories.includes("transparent") && allowedCategories) {
+ childrenCategories = Array.from(
+ new Set([...childrenCategories, ...allowedCategories])
+ );
+ }
+ return childrenCategories;
+};
+
+const computeAllowedComponentCategories = ({
+ instances,
+ metas,
+ instanceSelector,
+}: {
+ instances: Instances;
+ metas: Map;
+ instanceSelector: InstanceSelector;
+}) => {
+ let instance: undefined | Instance;
+ let allowedCategories: undefined | string[];
+ // skip selected instance for which these constraints are computed
+ for (const instanceId of instanceSelector.slice(1).reverse()) {
+ instance = instances.get(instanceId);
+ // collection item can be undefined
+ if (instance === undefined) {
+ continue;
+ }
+ allowedCategories = getComponentChildrenCategories({
+ metas,
+ component: instance.component,
+ allowedCategories,
+ });
+ }
+ return allowedCategories;
+};
+
/**
* Check all tags starting with specified instance select
* for example
@@ -213,6 +292,7 @@ export const isTreeSatisfyingContentModel = ({
instanceSelector,
onError,
_allowedCategories: allowedCategories,
+ _allowedComponentCategories: allowedComponentCategories,
}: {
instances: Instances;
props: Props;
@@ -220,6 +300,7 @@ export const isTreeSatisfyingContentModel = ({
instanceSelector: InstanceSelector;
onError?: (message: string) => void;
_allowedCategories?: string[];
+ _allowedComponentCategories?: string[];
}): boolean => {
// compute constraints only when not passed from parent
allowedCategories ??= computeAllowedCategories({
@@ -228,6 +309,11 @@ export const isTreeSatisfyingContentModel = ({
props,
metas,
});
+ allowedComponentCategories ??= computeAllowedComponentCategories({
+ instanceSelector,
+ instances,
+ metas,
+ });
const [instanceId, parentInstanceId] = instanceSelector;
const instance = instances.get(instanceId);
// collection item can be undefined
@@ -235,11 +321,11 @@ export const isTreeSatisfyingContentModel = ({
return true;
}
const tag = getTag({ instance, metas, props });
- let isSatisfying = isTagSatisfyingContentModel({
+ const isTagSatisfying = isTagSatisfyingContentModel({
tag,
allowedCategories,
});
- if (isSatisfying === false && allowedCategories) {
+ if (isTagSatisfying === false) {
const parentInstance = instances.get(parentInstanceId);
let parentTag: undefined | string;
if (parentInstance) {
@@ -253,10 +339,28 @@ export const isTreeSatisfyingContentModel = ({
onError?.(`Placing <${tag}> element here violates HTML spec.`);
}
}
- const childrenCategories: string[] = getTagChildrenCategories(
- tag,
- allowedCategories
- );
+ const isComponentSatisfying = isComponentSatisfyingContentModel({
+ metas,
+ component: instance.component,
+ allowedCategories: allowedComponentCategories,
+ });
+ if (isComponentSatisfying === false) {
+ const [_namespace, name] = parseComponentName(instance.component);
+ const parentInstance = instances.get(parentInstanceId);
+ let parentName: undefined | string;
+ if (parentInstance) {
+ const [_namespace, name] = parseComponentName(parentInstance.component);
+ parentName = name;
+ }
+ if (parentName) {
+ onError?.(
+ `Placing "${name}" element inside a "${parentName}" violates content model.`
+ );
+ } else {
+ onError?.(`Placing "${name}" element here violates content model.`);
+ }
+ }
+ let isSatisfying = isTagSatisfying && isComponentSatisfying;
for (const child of instance.children) {
if (child.type === "id") {
isSatisfying &&= isTreeSatisfyingContentModel({
@@ -265,7 +369,12 @@ export const isTreeSatisfyingContentModel = ({
metas,
instanceSelector: [child.value, ...instanceSelector],
onError,
- _allowedCategories: childrenCategories,
+ _allowedCategories: getTagChildrenCategories(tag, allowedCategories),
+ _allowedComponentCategories: getComponentChildrenCategories({
+ metas,
+ component: instance.component,
+ allowedCategories: allowedComponentCategories,
+ }),
});
}
}
diff --git a/packages/sdk-components-react/src/head-link.ws.ts b/packages/sdk-components-react/src/head-link.ws.ts
index 844579613be4..9dbcbaa9990b 100644
--- a/packages/sdk-components-react/src/head-link.ws.ts
+++ b/packages/sdk-components-react/src/head-link.ws.ts
@@ -3,16 +3,15 @@ import {
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
-
import { props } from "./__generated__/head-link.props";
export const meta: WsComponentMeta = {
category: "hidden",
icon: ResourceIcon,
type: "embed",
- constraints: {
- relation: "parent",
- component: { $eq: "HeadSlot" },
+ contentModel: {
+ category: "none",
+ children: ["empty"],
},
};
diff --git a/packages/sdk-components-react/src/head-meta.ws.ts b/packages/sdk-components-react/src/head-meta.ws.ts
index fd39fa823532..06b6c1e67801 100644
--- a/packages/sdk-components-react/src/head-meta.ws.ts
+++ b/packages/sdk-components-react/src/head-meta.ws.ts
@@ -3,16 +3,15 @@ import {
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
-
import { props } from "./__generated__/head-meta.props";
export const meta: WsComponentMeta = {
category: "hidden",
icon: WindowInfoIcon,
type: "embed",
- constraints: {
- relation: "parent",
- component: { $eq: "HeadSlot" },
+ contentModel: {
+ category: "none",
+ children: ["empty"],
},
};
diff --git a/packages/sdk-components-react/src/head-slot.ws.ts b/packages/sdk-components-react/src/head-slot.ws.ts
index e7054dd7f44c..28991e1dd5c1 100644
--- a/packages/sdk-components-react/src/head-slot.ws.ts
+++ b/packages/sdk-components-react/src/head-slot.ws.ts
@@ -3,23 +3,16 @@ import {
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
-
import { props } from "./__generated__/head.props";
export const meta: WsComponentMeta = {
icon: HeaderIcon,
type: "container",
description: "Inserts children into the head of the document",
- constraints: [
- {
- relation: "ancestor",
- component: { $nin: ["HeadSlot"] },
- },
- {
- relation: "child",
- component: { $in: ["HeadLink", "HeadMeta", "HeadTitle"] },
- },
- ],
+ contentModel: {
+ category: "instance",
+ children: ["HeadLink", "HeadMeta", "HeadTitle"],
+ },
};
export const propsMeta: WsComponentPropsMeta = {
diff --git a/packages/sdk-components-react/src/head-title.ws.ts b/packages/sdk-components-react/src/head-title.ws.ts
index 14cb860331e8..7b2ffb8fb596 100644
--- a/packages/sdk-components-react/src/head-title.ws.ts
+++ b/packages/sdk-components-react/src/head-title.ws.ts
@@ -3,16 +3,15 @@ import {
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
-
import { props } from "./__generated__/head-title.props";
export const meta: WsComponentMeta = {
category: "hidden",
icon: WindowTitleIcon,
type: "container",
- constraints: {
- relation: "parent",
- component: { $eq: "HeadSlot" },
+ contentModel: {
+ category: "none",
+ children: ["text"],
},
};
diff --git a/packages/sdk-components-react/src/html-embed.ws.ts b/packages/sdk-components-react/src/html-embed.ws.ts
index 991f0cadbbcd..e1e0ac00060d 100644
--- a/packages/sdk-components-react/src/html-embed.ws.ts
+++ b/packages/sdk-components-react/src/html-embed.ws.ts
@@ -1,32 +1,34 @@
import { EmbedIcon } from "@webstudio-is/icons/svg";
-import type {
- PresetStyle,
- WsComponentMeta,
- WsComponentPropsMeta,
+import {
+ descendantComponent,
+ type WsComponentMeta,
+ type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
import { props } from "./__generated__/html-embed.props";
-const presetStyle = {
- div: [
- {
- property: "display",
- value: { type: "keyword", value: "contents" },
- },
- {
- property: "white-space-collapse",
- value: { type: "keyword", value: "collapse" },
- },
- ],
-} satisfies PresetStyle<"div">;
-
export const meta: WsComponentMeta = {
category: "general",
- type: "embed",
+ type: "container",
label: "HTML Embed",
description: "Used to add HTML code to the page, such as an SVG or script.",
icon: EmbedIcon,
- presetStyle,
order: 2,
+ contentModel: {
+ category: "instance",
+ children: [descendantComponent],
+ },
+ presetStyle: {
+ div: [
+ {
+ property: "display",
+ value: { type: "keyword", value: "contents" },
+ },
+ {
+ property: "white-space-collapse",
+ value: { type: "keyword", value: "collapse" },
+ },
+ ],
+ },
};
export const propsMeta: WsComponentPropsMeta = {
diff --git a/packages/sdk-components-react/src/markdown-embed.ws.ts b/packages/sdk-components-react/src/markdown-embed.ws.ts
index df6c6fae92c1..48d11206443d 100644
--- a/packages/sdk-components-react/src/markdown-embed.ws.ts
+++ b/packages/sdk-components-react/src/markdown-embed.ws.ts
@@ -1,10 +1,18 @@
import { MarkdownEmbedIcon } from "@webstudio-is/icons/svg";
-import type { WsComponentMeta, WsComponentPropsMeta } from "@webstudio-is/sdk";
+import {
+ descendantComponent,
+ type WsComponentMeta,
+ type WsComponentPropsMeta,
+} from "@webstudio-is/sdk";
import { props } from "./__generated__/markdown-embed.props";
export const meta: WsComponentMeta = {
- type: "embed",
+ type: "container",
icon: MarkdownEmbedIcon,
+ contentModel: {
+ category: "instance",
+ children: [descendantComponent],
+ },
presetStyle: {
div: [
{
diff --git a/packages/sdk-components-react/src/vimeo-play-button.ws.ts b/packages/sdk-components-react/src/vimeo-play-button.ws.ts
index a0baee1cddc7..94e8370cc728 100644
--- a/packages/sdk-components-react/src/vimeo-play-button.ws.ts
+++ b/packages/sdk-components-react/src/vimeo-play-button.ws.ts
@@ -1,35 +1,25 @@
import {
defaultStates,
- type PresetStyle,
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
import { ButtonElementIcon } from "@webstudio-is/icons/svg";
import { button } from "@webstudio-is/sdk/normalize.css";
-import type { defaultTag } from "./vimeo-play-button";
import { props } from "./__generated__/vimeo-play-button.props";
-const presetStyle = {
- button,
-} satisfies PresetStyle;
-
export const meta: WsComponentMeta = {
category: "hidden",
type: "container",
- constraints: [
- {
- relation: "ancestor",
- component: { $in: ["Vimeo", "YouTube"] },
- },
- {
- relation: "ancestor",
- component: { $neq: "Button" },
- },
- ],
label: "Play Button",
icon: ButtonElementIcon,
- presetStyle,
states: defaultStates,
+ contentModel: {
+ category: "none",
+ children: ["instance"],
+ },
+ presetStyle: {
+ button,
+ },
};
export const propsMeta: WsComponentPropsMeta = {
diff --git a/packages/sdk-components-react/src/vimeo-preview-image.ws.ts b/packages/sdk-components-react/src/vimeo-preview-image.ws.ts
index da80c6f107cd..a40af7c03818 100644
--- a/packages/sdk-components-react/src/vimeo-preview-image.ws.ts
+++ b/packages/sdk-components-react/src/vimeo-preview-image.ws.ts
@@ -6,9 +6,9 @@ export const meta: WsComponentMeta = {
...imageMeta,
category: "hidden",
label: "Preview Image",
- constraints: {
- relation: "ancestor",
- component: { $in: ["Vimeo", "YouTube"] },
+ contentModel: {
+ category: "none",
+ children: ["empty"],
},
};
diff --git a/packages/sdk-components-react/src/vimeo-spinner.ws.ts b/packages/sdk-components-react/src/vimeo-spinner.ws.ts
index e9df12507cc3..78ce97d3073b 100644
--- a/packages/sdk-components-react/src/vimeo-spinner.ws.ts
+++ b/packages/sdk-components-react/src/vimeo-spinner.ws.ts
@@ -1,6 +1,5 @@
import {
defaultStates,
- type PresetStyle,
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
@@ -8,21 +7,19 @@ import { div } from "@webstudio-is/sdk/normalize.css";
import { BoxIcon } from "@webstudio-is/icons/svg";
import { props } from "./__generated__/vimeo-spinner.props";
-const presetStyle = {
- div,
-} satisfies PresetStyle<"div">;
-
export const meta: WsComponentMeta = {
type: "container",
- constraints: {
- relation: "ancestor",
- component: { $in: ["Vimeo", "YouTube"] },
- },
icon: BoxIcon,
states: defaultStates,
- presetStyle,
category: "hidden",
label: "Spinner",
+ contentModel: {
+ category: "none",
+ children: ["instance"],
+ },
+ presetStyle: {
+ div,
+ },
};
export const propsMeta: WsComponentPropsMeta = {
diff --git a/packages/sdk-components-react/src/vimeo.ws.ts b/packages/sdk-components-react/src/vimeo.ws.ts
index 9d05047c5725..8e3136e20912 100644
--- a/packages/sdk-components-react/src/vimeo.ws.ts
+++ b/packages/sdk-components-react/src/vimeo.ws.ts
@@ -2,7 +2,6 @@ import type { ComponentProps } from "react";
import { VimeoIcon } from "@webstudio-is/icons/svg";
import {
defaultStates,
- type PresetStyle,
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
@@ -10,18 +9,21 @@ import { div } from "@webstudio-is/sdk/normalize.css";
import { props } from "./__generated__/vimeo.props";
import type { Vimeo } from "./vimeo";
-const presetStyle = {
- div,
-} satisfies PresetStyle<"div">;
-
export const meta: WsComponentMeta = {
type: "container",
icon: VimeoIcon,
states: defaultStates,
- presetStyle,
- constraints: {
- relation: "ancestor",
- component: { $nin: ["Button", "Link", "Heading"] },
+ contentModel: {
+ category: "instance",
+ children: [
+ "instance",
+ "VimeoSpinner",
+ "VimeoPlayButton",
+ "VimeoPreviewImage",
+ ],
+ },
+ presetStyle: {
+ div,
},
};
diff --git a/packages/sdk-components-react/src/youtube.ws.ts b/packages/sdk-components-react/src/youtube.ws.ts
index face4d1a3f0e..188ec528a0ed 100644
--- a/packages/sdk-components-react/src/youtube.ws.ts
+++ b/packages/sdk-components-react/src/youtube.ws.ts
@@ -2,7 +2,6 @@ import type { ComponentProps } from "react";
import { YoutubeIcon } from "@webstudio-is/icons/svg";
import {
defaultStates,
- type PresetStyle,
type WsComponentMeta,
type WsComponentPropsMeta,
} from "@webstudio-is/sdk";
@@ -10,18 +9,21 @@ import { div } from "@webstudio-is/sdk/normalize.css";
import { props } from "./__generated__/youtube.props";
import type { YouTube } from "./youtube";
-const presetStyle = {
- div,
-} satisfies PresetStyle<"div">;
-
export const meta: WsComponentMeta = {
type: "container",
icon: YoutubeIcon,
states: defaultStates,
- presetStyle,
- constraints: {
- relation: "ancestor",
- component: { $nin: ["Button", "Link", "Heading"] },
+ contentModel: {
+ category: "instance",
+ children: [
+ "instance",
+ "VimeoSpinner",
+ "VimeoPlayButton",
+ "VimeoPreviewImage",
+ ],
+ },
+ presetStyle: {
+ div,
},
};
diff --git a/packages/sdk/src/core-metas.ts b/packages/sdk/src/core-metas.ts
index 702f5101e928..191f5e5d7f76 100644
--- a/packages/sdk/src/core-metas.ts
+++ b/packages/sdk/src/core-metas.ts
@@ -66,12 +66,12 @@ const descendantMeta: WsComponentMeta = {
type: "control",
label: "Descendant",
icon: PaintBrushIcon,
+ contentModel: {
+ category: "none",
+ children: ["empty"],
+ },
// @todo infer possible presets
presetStyle: {},
- constraints: {
- relation: "parent",
- component: { $in: ["HtmlEmbed", "MarkdownEmbed"] },
- },
};
const descendantPropsMeta: WsComponentPropsMeta = {
@@ -110,9 +110,9 @@ export const blockTemplateComponent = "ws:block-template";
export const blockTemplateMeta: WsComponentMeta = {
type: "container",
icon: AddTemplateInstanceIcon,
- constraints: {
- relation: "parent",
- component: { $eq: blockComponent },
+ contentModel: {
+ category: "none",
+ children: ["instance"],
},
};
@@ -124,16 +124,11 @@ const blockMeta: WsComponentMeta = {
type: "container",
label: "Content Block",
icon: ContentBlockIcon,
- constraints: [
- {
- relation: "ancestor",
- component: { $nin: [collectionComponent, blockComponent] },
- },
- {
- relation: "child",
- component: { $eq: blockTemplateComponent },
- },
- ],
+ contentModel: {
+ category: "instance",
+ children: [blockTemplateComponent, "instance"],
+ // @todo prevent deleting block template
+ },
};
const blockPropsMeta: WsComponentPropsMeta = {
diff --git a/packages/sdk/src/schema/component-meta.ts b/packages/sdk/src/schema/component-meta.ts
index cfe902822811..a9169f72e601 100644
--- a/packages/sdk/src/schema/component-meta.ts
+++ b/packages/sdk/src/schema/component-meta.ts
@@ -64,6 +64,28 @@ export const defaultStates: ComponentState[] = [
{ selector: ":focus-within", label: "Focus Within" },
];
+export const ContentModel = z.object({
+ /*
+ * instance - accepted by any parent with "instance" in children categories
+ * none - accepted by parents with this component name in children categories
+ */
+ category: z.union([z.literal("instance"), z.literal("none")]),
+ /**
+ * transparent - pass through possible children from parent
+ * rich-text - can be edited as rich text
+ * instance - other instances accepted
+ * empty - no children accepted
+ * ComponentName - accept specific components with none category
+ */
+ children: z.array(
+ z.string() as z.ZodType<
+ "transparent" | "instance" | "rich-text" | "empty" | (string & {})
+ >
+ ),
+});
+
+export type ContentModel = z.infer;
+
export const WsComponentMeta = z.object({
category: z.enum(componentCategories).optional(),
// container - can accept other components with dnd or be edited as text
@@ -77,6 +99,7 @@ export const WsComponentMeta = z.object({
*/
placeholder: z.string().optional(),
constraints: Matchers.optional(),
+ contentModel: ContentModel.optional(),
// when this field is specified component receives
// prop with index of same components withiin specified ancestor
// important to automatically enumerate collections without