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> + + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeTruthy(); + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.HtmlEmbed> + + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeFalsy(); + }); + + test("restrict components within specific ancestor", () => { + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.Vimeo> + <$.VimeoSpinner> + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeTruthy(); + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.Vimeo> + + <$.VimeoSpinner> + + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeTruthy(); + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.VimeoSpinner> + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeFalsy(); + }); + + test("prevent self nesting with descendants restriction", () => { + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.Vimeo> + <$.VimeoSpinner> + <$.VimeoSpinner> + + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeFalsy(); + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.Vimeo> + <$.VimeoSpinner> + <$.Vimeo> + <$.VimeoSpinner> + + + + + ), + metas: defaultMetas, + instanceSelector: ["bodyId"], + }) + ).toBeTruthy(); + }); + + test("pass constraints when check deep in the tree", () => { + expect( + isTreeSatisfyingContentModel({ + ...renderData( + + <$.Vimeo ws:id="vimeoId"> + + <$.VimeoSpinner> + + + + ), + 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