Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion apps/builder/app/shared/content-model.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -482,3 +482,129 @@ test("edge case: support a > img", () => {
})
).toBeTruthy();
});

describe("component content model", () => {
test("restrict children with specific component", () => {
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.HtmlEmbed>
<ws.descendant />
</$.HtmlEmbed>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["bodyId"],
})
).toBeTruthy();
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.HtmlEmbed>
<ws.element ws:tag="div" />
</$.HtmlEmbed>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["bodyId"],
})
).toBeFalsy();
});

test("restrict components within specific ancestor", () => {
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Vimeo>
<$.VimeoSpinner></$.VimeoSpinner>
</$.Vimeo>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["bodyId"],
})
).toBeTruthy();
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Vimeo>
<ws.element ws:tag="div">
<$.VimeoSpinner></$.VimeoSpinner>
</ws.element>
</$.Vimeo>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["bodyId"],
})
).toBeTruthy();
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.VimeoSpinner></$.VimeoSpinner>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["bodyId"],
})
).toBeFalsy();
});

test("prevent self nesting with descendants restriction", () => {
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Vimeo>
<$.VimeoSpinner>
<$.VimeoSpinner></$.VimeoSpinner>
</$.VimeoSpinner>
</$.Vimeo>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["bodyId"],
})
).toBeFalsy();
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Vimeo>
<$.VimeoSpinner>
<$.Vimeo>
<$.VimeoSpinner></$.VimeoSpinner>
</$.Vimeo>
</$.VimeoSpinner>
</$.Vimeo>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["bodyId"],
})
).toBeTruthy();
});

test("pass constraints when check deep in the tree", () => {
expect(
isTreeSatisfyingContentModel({
...renderData(
<ws.element ws:tag="body" ws:id="bodyId">
<$.Vimeo ws:id="vimeoId">
<ws.element ws:tag="div" ws:id="divId">
<$.VimeoSpinner></$.VimeoSpinner>
</ws.element>
</$.Vimeo>
</ws.element>
),
metas: defaultMetas,
instanceSelector: ["divId", "vimeoId", "bodyId"],
})
).toBeTruthy();
});
});
133 changes: 121 additions & 12 deletions apps/builder/app/shared/content-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<Instance["component"], WsComponentMeta>;
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<Instance["component"], WsComponentMeta>;
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<Instance["component"], WsComponentMeta>;
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
Expand Down Expand Up @@ -213,13 +292,15 @@ export const isTreeSatisfyingContentModel = ({
instanceSelector,
onError,
_allowedCategories: allowedCategories,
_allowedComponentCategories: allowedComponentCategories,
}: {
instances: Instances;
props: Props;
metas: Map<Instance["component"], WsComponentMeta>;
instanceSelector: InstanceSelector;
onError?: (message: string) => void;
_allowedCategories?: string[];
_allowedComponentCategories?: string[];
}): boolean => {
// compute constraints only when not passed from parent
allowedCategories ??= computeAllowedCategories({
Expand All @@ -228,18 +309,23 @@ export const isTreeSatisfyingContentModel = ({
props,
metas,
});
allowedComponentCategories ??= computeAllowedComponentCategories({
instanceSelector,
instances,
metas,
});
const [instanceId, parentInstanceId] = instanceSelector;
const instance = instances.get(instanceId);
// collection item can be undefined
if (instance === undefined) {
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) {
Expand All @@ -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({
Expand All @@ -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,
}),
});
}
}
Expand Down
7 changes: 3 additions & 4 deletions packages/sdk-components-react/src/head-link.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
};

Expand Down
7 changes: 3 additions & 4 deletions packages/sdk-components-react/src/head-meta.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
},
};

Expand Down
15 changes: 4 additions & 11 deletions packages/sdk-components-react/src/head-slot.ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading