Skip to content

Commit 4b52aaa

Browse files
authored
refactor: add component content model (#5101)
Extended content model to support categories defined in component meta. Most components has transparent children categories to pass through constraints from parent, this way we allow descendants with non category. Here migrated only core and base components. Will extend more for animations in another PR.
1 parent 9cc131a commit 4b52aaa

15 files changed

+366
-122
lines changed

apps/builder/app/shared/content-model.test.tsx

+127-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { expect, test } from "vitest";
1+
import { describe, expect, test } from "vitest";
22
import { coreMetas } from "@webstudio-is/sdk";
33
import * as baseComponentMetas from "@webstudio-is/sdk-components-react/metas";
44
import { $, renderData, ws } from "@webstudio-is/template";
@@ -482,3 +482,129 @@ test("edge case: support a > img", () => {
482482
})
483483
).toBeTruthy();
484484
});
485+
486+
describe("component content model", () => {
487+
test("restrict children with specific component", () => {
488+
expect(
489+
isTreeSatisfyingContentModel({
490+
...renderData(
491+
<ws.element ws:tag="body" ws:id="bodyId">
492+
<$.HtmlEmbed>
493+
<ws.descendant />
494+
</$.HtmlEmbed>
495+
</ws.element>
496+
),
497+
metas: defaultMetas,
498+
instanceSelector: ["bodyId"],
499+
})
500+
).toBeTruthy();
501+
expect(
502+
isTreeSatisfyingContentModel({
503+
...renderData(
504+
<ws.element ws:tag="body" ws:id="bodyId">
505+
<$.HtmlEmbed>
506+
<ws.element ws:tag="div" />
507+
</$.HtmlEmbed>
508+
</ws.element>
509+
),
510+
metas: defaultMetas,
511+
instanceSelector: ["bodyId"],
512+
})
513+
).toBeFalsy();
514+
});
515+
516+
test("restrict components within specific ancestor", () => {
517+
expect(
518+
isTreeSatisfyingContentModel({
519+
...renderData(
520+
<ws.element ws:tag="body" ws:id="bodyId">
521+
<$.Vimeo>
522+
<$.VimeoSpinner></$.VimeoSpinner>
523+
</$.Vimeo>
524+
</ws.element>
525+
),
526+
metas: defaultMetas,
527+
instanceSelector: ["bodyId"],
528+
})
529+
).toBeTruthy();
530+
expect(
531+
isTreeSatisfyingContentModel({
532+
...renderData(
533+
<ws.element ws:tag="body" ws:id="bodyId">
534+
<$.Vimeo>
535+
<ws.element ws:tag="div">
536+
<$.VimeoSpinner></$.VimeoSpinner>
537+
</ws.element>
538+
</$.Vimeo>
539+
</ws.element>
540+
),
541+
metas: defaultMetas,
542+
instanceSelector: ["bodyId"],
543+
})
544+
).toBeTruthy();
545+
expect(
546+
isTreeSatisfyingContentModel({
547+
...renderData(
548+
<ws.element ws:tag="body" ws:id="bodyId">
549+
<$.VimeoSpinner></$.VimeoSpinner>
550+
</ws.element>
551+
),
552+
metas: defaultMetas,
553+
instanceSelector: ["bodyId"],
554+
})
555+
).toBeFalsy();
556+
});
557+
558+
test("prevent self nesting with descendants restriction", () => {
559+
expect(
560+
isTreeSatisfyingContentModel({
561+
...renderData(
562+
<ws.element ws:tag="body" ws:id="bodyId">
563+
<$.Vimeo>
564+
<$.VimeoSpinner>
565+
<$.VimeoSpinner></$.VimeoSpinner>
566+
</$.VimeoSpinner>
567+
</$.Vimeo>
568+
</ws.element>
569+
),
570+
metas: defaultMetas,
571+
instanceSelector: ["bodyId"],
572+
})
573+
).toBeFalsy();
574+
expect(
575+
isTreeSatisfyingContentModel({
576+
...renderData(
577+
<ws.element ws:tag="body" ws:id="bodyId">
578+
<$.Vimeo>
579+
<$.VimeoSpinner>
580+
<$.Vimeo>
581+
<$.VimeoSpinner></$.VimeoSpinner>
582+
</$.Vimeo>
583+
</$.VimeoSpinner>
584+
</$.Vimeo>
585+
</ws.element>
586+
),
587+
metas: defaultMetas,
588+
instanceSelector: ["bodyId"],
589+
})
590+
).toBeTruthy();
591+
});
592+
593+
test("pass constraints when check deep in the tree", () => {
594+
expect(
595+
isTreeSatisfyingContentModel({
596+
...renderData(
597+
<ws.element ws:tag="body" ws:id="bodyId">
598+
<$.Vimeo ws:id="vimeoId">
599+
<ws.element ws:tag="div" ws:id="divId">
600+
<$.VimeoSpinner></$.VimeoSpinner>
601+
</ws.element>
602+
</$.Vimeo>
603+
</ws.element>
604+
),
605+
metas: defaultMetas,
606+
instanceSelector: ["divId", "vimeoId", "bodyId"],
607+
})
608+
).toBeTruthy();
609+
});
610+
});

apps/builder/app/shared/content-model.ts

+121-12
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@ import {
22
categoriesByTag,
33
childrenCategoriesByTag,
44
} from "@webstudio-is/html-data";
5-
import type {
6-
Instance,
7-
Instances,
8-
Props,
9-
WsComponentMeta,
5+
import {
6+
parseComponentName,
7+
type ContentModel,
8+
type Instance,
9+
type Instances,
10+
type Props,
11+
type WsComponentMeta,
1012
} from "@webstudio-is/sdk";
1113
import type { InstanceSelector } from "./tree-utils";
1214

@@ -171,6 +173,83 @@ const computeAllowedCategories = ({
171173
return allowedCategories;
172174
};
173175

176+
const defaultComponentContentModel: ContentModel = {
177+
category: "instance",
178+
children: ["rich-text", "instance", "transparent"],
179+
};
180+
181+
const isComponentSatisfyingContentModel = ({
182+
metas,
183+
component,
184+
allowedCategories,
185+
}: {
186+
metas: Map<Instance["component"], WsComponentMeta>;
187+
component: string;
188+
allowedCategories: undefined | string[];
189+
}) => {
190+
const contentModel = {
191+
...defaultComponentContentModel,
192+
...metas.get(component)?.contentModel,
193+
};
194+
return (
195+
// body does not have parent
196+
allowedCategories === undefined ||
197+
// parents may restrict specific components with none category
198+
// any instances
199+
// or nothing
200+
allowedCategories.includes(component) ||
201+
allowedCategories.includes(contentModel.category)
202+
);
203+
};
204+
205+
const getComponentChildrenCategories = ({
206+
metas,
207+
component,
208+
allowedCategories,
209+
}: {
210+
metas: Map<Instance["component"], WsComponentMeta>;
211+
component: string;
212+
allowedCategories: undefined | string[];
213+
}) => {
214+
const contentModel =
215+
metas.get(component)?.contentModel ?? defaultComponentContentModel;
216+
let childrenCategories = contentModel.children;
217+
// transparent categories makes component inherit constraints from parent
218+
if (childrenCategories.includes("transparent") && allowedCategories) {
219+
childrenCategories = Array.from(
220+
new Set([...childrenCategories, ...allowedCategories])
221+
);
222+
}
223+
return childrenCategories;
224+
};
225+
226+
const computeAllowedComponentCategories = ({
227+
instances,
228+
metas,
229+
instanceSelector,
230+
}: {
231+
instances: Instances;
232+
metas: Map<Instance["component"], WsComponentMeta>;
233+
instanceSelector: InstanceSelector;
234+
}) => {
235+
let instance: undefined | Instance;
236+
let allowedCategories: undefined | string[];
237+
// skip selected instance for which these constraints are computed
238+
for (const instanceId of instanceSelector.slice(1).reverse()) {
239+
instance = instances.get(instanceId);
240+
// collection item can be undefined
241+
if (instance === undefined) {
242+
continue;
243+
}
244+
allowedCategories = getComponentChildrenCategories({
245+
metas,
246+
component: instance.component,
247+
allowedCategories,
248+
});
249+
}
250+
return allowedCategories;
251+
};
252+
174253
/**
175254
* Check all tags starting with specified instance select
176255
* for example
@@ -213,13 +292,15 @@ export const isTreeSatisfyingContentModel = ({
213292
instanceSelector,
214293
onError,
215294
_allowedCategories: allowedCategories,
295+
_allowedComponentCategories: allowedComponentCategories,
216296
}: {
217297
instances: Instances;
218298
props: Props;
219299
metas: Map<Instance["component"], WsComponentMeta>;
220300
instanceSelector: InstanceSelector;
221301
onError?: (message: string) => void;
222302
_allowedCategories?: string[];
303+
_allowedComponentCategories?: string[];
223304
}): boolean => {
224305
// compute constraints only when not passed from parent
225306
allowedCategories ??= computeAllowedCategories({
@@ -228,18 +309,23 @@ export const isTreeSatisfyingContentModel = ({
228309
props,
229310
metas,
230311
});
312+
allowedComponentCategories ??= computeAllowedComponentCategories({
313+
instanceSelector,
314+
instances,
315+
metas,
316+
});
231317
const [instanceId, parentInstanceId] = instanceSelector;
232318
const instance = instances.get(instanceId);
233319
// collection item can be undefined
234320
if (instance === undefined) {
235321
return true;
236322
}
237323
const tag = getTag({ instance, metas, props });
238-
let isSatisfying = isTagSatisfyingContentModel({
324+
const isTagSatisfying = isTagSatisfyingContentModel({
239325
tag,
240326
allowedCategories,
241327
});
242-
if (isSatisfying === false && allowedCategories) {
328+
if (isTagSatisfying === false) {
243329
const parentInstance = instances.get(parentInstanceId);
244330
let parentTag: undefined | string;
245331
if (parentInstance) {
@@ -253,10 +339,28 @@ export const isTreeSatisfyingContentModel = ({
253339
onError?.(`Placing <${tag}> element here violates HTML spec.`);
254340
}
255341
}
256-
const childrenCategories: string[] = getTagChildrenCategories(
257-
tag,
258-
allowedCategories
259-
);
342+
const isComponentSatisfying = isComponentSatisfyingContentModel({
343+
metas,
344+
component: instance.component,
345+
allowedCategories: allowedComponentCategories,
346+
});
347+
if (isComponentSatisfying === false) {
348+
const [_namespace, name] = parseComponentName(instance.component);
349+
const parentInstance = instances.get(parentInstanceId);
350+
let parentName: undefined | string;
351+
if (parentInstance) {
352+
const [_namespace, name] = parseComponentName(parentInstance.component);
353+
parentName = name;
354+
}
355+
if (parentName) {
356+
onError?.(
357+
`Placing "${name}" element inside a "${parentName}" violates content model.`
358+
);
359+
} else {
360+
onError?.(`Placing "${name}" element here violates content model.`);
361+
}
362+
}
363+
let isSatisfying = isTagSatisfying && isComponentSatisfying;
260364
for (const child of instance.children) {
261365
if (child.type === "id") {
262366
isSatisfying &&= isTreeSatisfyingContentModel({
@@ -265,7 +369,12 @@ export const isTreeSatisfyingContentModel = ({
265369
metas,
266370
instanceSelector: [child.value, ...instanceSelector],
267371
onError,
268-
_allowedCategories: childrenCategories,
372+
_allowedCategories: getTagChildrenCategories(tag, allowedCategories),
373+
_allowedComponentCategories: getComponentChildrenCategories({
374+
metas,
375+
component: instance.component,
376+
allowedCategories: allowedComponentCategories,
377+
}),
269378
});
270379
}
271380
}

packages/sdk-components-react/src/head-link.ws.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@ import {
33
type WsComponentMeta,
44
type WsComponentPropsMeta,
55
} from "@webstudio-is/sdk";
6-
76
import { props } from "./__generated__/head-link.props";
87

98
export const meta: WsComponentMeta = {
109
category: "hidden",
1110
icon: ResourceIcon,
1211
type: "embed",
13-
constraints: {
14-
relation: "parent",
15-
component: { $eq: "HeadSlot" },
12+
contentModel: {
13+
category: "none",
14+
children: ["empty"],
1615
},
1716
};
1817

packages/sdk-components-react/src/head-meta.ws.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,15 @@ import {
33
type WsComponentMeta,
44
type WsComponentPropsMeta,
55
} from "@webstudio-is/sdk";
6-
76
import { props } from "./__generated__/head-meta.props";
87

98
export const meta: WsComponentMeta = {
109
category: "hidden",
1110
icon: WindowInfoIcon,
1211
type: "embed",
13-
constraints: {
14-
relation: "parent",
15-
component: { $eq: "HeadSlot" },
12+
contentModel: {
13+
category: "none",
14+
children: ["empty"],
1615
},
1716
};
1817

packages/sdk-components-react/src/head-slot.ws.ts

+4-11
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,16 @@ import {
33
type WsComponentMeta,
44
type WsComponentPropsMeta,
55
} from "@webstudio-is/sdk";
6-
76
import { props } from "./__generated__/head.props";
87

98
export const meta: WsComponentMeta = {
109
icon: HeaderIcon,
1110
type: "container",
1211
description: "Inserts children into the head of the document",
13-
constraints: [
14-
{
15-
relation: "ancestor",
16-
component: { $nin: ["HeadSlot"] },
17-
},
18-
{
19-
relation: "child",
20-
component: { $in: ["HeadLink", "HeadMeta", "HeadTitle"] },
21-
},
22-
],
12+
contentModel: {
13+
category: "instance",
14+
children: ["HeadLink", "HeadMeta", "HeadTitle"],
15+
},
2316
};
2417

2518
export const propsMeta: WsComponentPropsMeta = {

0 commit comments

Comments
 (0)