Skip to content

refactor: add component content model #5101

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Apr 8, 2025
Merged
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";
@@ -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
@@ -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<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
@@ -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({
@@ -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) {
@@ -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,
}),
});
}
}
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
@@ -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"],
},
};

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
@@ -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"],
},
};

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
@@ -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 = {
Loading