@comet/agent-features 9.0.0-canary-20260527154746

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/LICENSE +24 -0
  2. package/package.json +19 -0
  3. package/rules/coding-guidelines/api-nestjs.instructions.md +107 -0
  4. package/rules/coding-guidelines/cdn.instructions.md +24 -0
  5. package/rules/coding-guidelines/general.instructions.md +30 -0
  6. package/rules/coding-guidelines/git.instructions.md +37 -0
  7. package/rules/coding-guidelines/kubernetes.instructions.md +59 -0
  8. package/rules/coding-guidelines/libraries.instructions.md +34 -0
  9. package/rules/coding-guidelines/naming.instructions.md +39 -0
  10. package/rules/coding-guidelines/postgresql.instructions.md +40 -0
  11. package/rules/coding-guidelines/react.instructions.md +102 -0
  12. package/rules/coding-guidelines/security.instructions.md +44 -0
  13. package/rules/coding-guidelines/styling.instructions.md +50 -0
  14. package/rules/coding-guidelines/typescript.instructions.md +50 -0
  15. package/skills/.gitkeep +0 -0
  16. package/skills/comet-block/SKILL.md +246 -0
  17. package/skills/comet-block/references/admin-patterns.md +192 -0
  18. package/skills/comet-block/references/api-patterns.md +183 -0
  19. package/skills/comet-block/references/block-loader.md +368 -0
  20. package/skills/comet-block/references/block-types.md +210 -0
  21. package/skills/comet-block/references/custom-block-field.md +266 -0
  22. package/skills/comet-block/references/fixtures.md +436 -0
  23. package/skills/comet-block/references/image.md +341 -0
  24. package/skills/comet-block/references/migration.md +597 -0
  25. package/skills/comet-block/references/registration.md +167 -0
  26. package/skills/comet-block/references/response-summary.md +102 -0
  27. package/skills/comet-block/references/rich-text.md +309 -0
  28. package/skills/comet-block/references/select.md +176 -0
  29. package/skills/comet-block/references/site-patterns.md +202 -0
  30. package/skills/comet-mail-react/SKILL.md +539 -0
  31. package/skills/comet-mail-react/references/components-and-theme.md +395 -0
  32. package/skills/comet-mail-react/references/layout-patterns.md +315 -0
  33. package/skills/comet-mail-react/references/styling-and-customization.md +306 -0
  34. package/skills/comet-minor-update/SKILL.md +191 -0
  35. package/skills/dev-pm/SKILL.md +100 -0
@@ -0,0 +1,368 @@
1
+ # Block Loader Rules
2
+
3
+ Detailed rules for creating block loaders that fetch server-side data in the site layer. Load this file when a block references an entity by ID and needs to resolve that entity's data at render time.
4
+
5
+ ---
6
+
7
+ ## Overview
8
+
9
+ A block loader is an async function that runs server-side before the block component renders. It fetches data (typically from the Comet GraphQL API via `graphQLFetch`) and attaches the result to the block's data under a `loaded` property. The `recursivelyLoadBlockData` utility walks the block tree, finds blocks with registered loaders, and runs them in parallel.
10
+
11
+ ---
12
+
13
+ ## When to use
14
+
15
+ | Scenario | Use a block loader? |
16
+ | -------------------------------------------------------------------- | ------------------- |
17
+ | Block stores an entity ID and needs the entity's data at render time | Yes |
18
+ | Block stores a list of entity IDs and needs to resolve them | Yes |
19
+ | Block needs scope-aware server-side data (e.g., page tree nodes) | Yes |
20
+ | Block only contains static content (text, images, enums) | No |
21
+ | Data is already embedded in the block's persisted state | No |
22
+
23
+ ---
24
+
25
+ ## File structure
26
+
27
+ Each loader lives alongside its block component in the site blocks directory:
28
+
29
+ ```
30
+ site/src/.../blocks/
31
+ ├── MyEntityBlock.tsx # Site component
32
+ ├── MyEntityBlock.loader.ts # Loader function
33
+ └── MyEntityBlock.loader.generated.ts # Auto-generated GraphQL types
34
+ ```
35
+
36
+ **Naming convention:** `{BlockName}Block.loader.ts` — matches the site component file name with `.loader` inserted before the extension.
37
+
38
+ ---
39
+
40
+ ## Loader function
41
+
42
+ ### Signature
43
+
44
+ ```ts
45
+ import { type BlockLoaderOptions, gql } from "@comet/site-nextjs";
46
+ import { type MyBlockData } from "@src/blocks.generated";
47
+
48
+ import { type GQLMyBlockQuery, type GQLMyBlockQueryVariables } from "./MyBlock.loader.generated";
49
+
50
+ export type LoadedData = Awaited<ReturnType<typeof loader>>;
51
+
52
+ export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions<MyBlockData>) => {
53
+ // Fetch and return data
54
+ };
55
+ ```
56
+
57
+ ### Key imports
58
+
59
+ | Import | Source | Purpose |
60
+ | --------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------- |
61
+ | `BlockLoaderOptions` | `@comet/site-nextjs` | Types the loader function parameter (provides `blockData`, `graphQLFetch`, `fetch`, and any augmented dependencies) |
62
+ | `gql` | `@comet/site-nextjs` | Tagged template literal for GraphQL queries |
63
+ | Block data type | `@src/blocks.generated` | Type for the block's persisted data (`blockData` parameter) |
64
+ | Generated query types | `./MyBlock.loader.generated` | Auto-generated types for the GraphQL query and variables |
65
+
66
+ ### `LoadedData` type export
67
+
68
+ Always export the loader's return type so the site component can import it:
69
+
70
+ ```ts
71
+ export type LoadedData = Awaited<ReturnType<typeof loader>>;
72
+ ```
73
+
74
+ This keeps the component's type in sync with the loader automatically — no manual type definitions needed.
75
+
76
+ ### Accessing additional dependencies
77
+
78
+ Projects augment `BlockLoaderDependencies` (via module declaration merging in `site/src/util/recursivelyLoadBlockData.ts`) to provide additional context such as `scope` and `pageTreeNodeId`. These are available on the loader's options object:
79
+
80
+ ```ts
81
+ export const loader = async ({ blockData, graphQLFetch, scope }: BlockLoaderOptions<MyBlockData>) => {
82
+ // `scope` is available because the project augments BlockLoaderDependencies
83
+ };
84
+ ```
85
+
86
+ Check the project's `recursivelyLoadBlockData.ts` for the available augmented dependencies.
87
+
88
+ ---
89
+
90
+ ## Common loader patterns
91
+
92
+ ### Single entity by ID
93
+
94
+ Fetch one entity by its stored ID. Return `null` when the ID is missing or the entity is not found.
95
+
96
+ ```ts
97
+ export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions<MyEntityBlockData>) => {
98
+ if (!blockData.id) return null;
99
+
100
+ const data = await graphQLFetch<GQLMyEntityBlockQuery, GQLMyEntityBlockQueryVariables>(
101
+ gql`
102
+ query MyEntityBlock($id: ID!) {
103
+ myEntity(id: $id) {
104
+ id
105
+ title
106
+ description
107
+ }
108
+ }
109
+ `,
110
+ { id: blockData.id },
111
+ );
112
+
113
+ return data.myEntity;
114
+ };
115
+ ```
116
+
117
+ ### Multiple entities by IDs
118
+
119
+ Fetch a list of entities. Return an empty array when no IDs are stored.
120
+
121
+ ```ts
122
+ export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions<MyEntityListBlockData>) => {
123
+ if (blockData.ids.length === 0) return [];
124
+
125
+ const data = await graphQLFetch<GQLMyEntityListBlockQuery, GQLMyEntityListBlockQueryVariables>(
126
+ gql`
127
+ query MyEntityListBlock($ids: [ID!]!) {
128
+ myEntitiesByIds(ids: $ids) {
129
+ id
130
+ title
131
+ slug
132
+ }
133
+ }
134
+ `,
135
+ { ids: blockData.ids },
136
+ );
137
+
138
+ return data.myEntitiesByIds;
139
+ };
140
+ ```
141
+
142
+ ### Scope-aware queries
143
+
144
+ Use the augmented `scope` dependency when the query requires a content scope.
145
+
146
+ ```ts
147
+ export const loader = async ({ graphQLFetch, scope }: BlockLoaderOptions<MyIndexBlockData>) => {
148
+ const data = await graphQLFetch<GQLMyIndexQuery, GQLMyIndexQueryVariables>(
149
+ gql`
150
+ query MyIndex($scope: ContentScopeInput!) {
151
+ myEntities(scope: $scope) {
152
+ id
153
+ title
154
+ path
155
+ }
156
+ }
157
+ `,
158
+ { scope },
159
+ );
160
+
161
+ return data.myEntities;
162
+ };
163
+ ```
164
+
165
+ ### Entity with nested block content
166
+
167
+ When a block loader fetches an entity that has a field containing block data (e.g. a blocks-block such as page content), that nested block data must be passed through `recursivelyLoadBlockData` so that nested blocks' loaders run. Otherwise, nested blocks that depend on loaded data receive `undefined` and may throw or render incorrectly.
168
+
169
+ This pattern commonly applies to blocks like **GlobalContent**, where the block stores an entity ID and the entity's content field holds a full block tree (e.g. `PageContent`) that may contain blocks with their own loaders.
170
+
171
+ After fetching the entity, call `recursivelyLoadBlockData` on the nested block field, passing the same dependencies your loader receives (e.g. `scope`, `graphQLFetch`, `fetch`). Use the correct root block type name for the nested content (e.g. `"PageContent"`).
172
+
173
+ ```ts
174
+ import { type BlockLoader, gql } from "@comet/site-nextjs";
175
+ import { type MyContentBlockData } from "@src/blocks.generated";
176
+ import { recursivelyLoadBlockData } from "@src/util/recursivelyLoadBlockData";
177
+
178
+ import { type GQLMyContentQuery, type GQLMyContentQueryVariables } from "./MyContentBlock.loader.generated";
179
+
180
+ export const loader: BlockLoader<MyContentBlockData> = async ({ blockData, graphQLFetch, scope, fetch }) => {
181
+ if (!blockData.id) return { entity: null };
182
+
183
+ const { entity } = await graphQLFetch<GQLMyContentQuery, GQLMyContentQueryVariables>(
184
+ gql`
185
+ query MyContent($id: ID!) {
186
+ entity(id: $id) {
187
+ id
188
+ content
189
+ }
190
+ }
191
+ `,
192
+ { id: blockData.id },
193
+ );
194
+
195
+ entity.content = await recursivelyLoadBlockData({
196
+ blockData: entity.content,
197
+ blockType: "PageContent",
198
+ scope,
199
+ fetch,
200
+ graphQLFetch,
201
+ });
202
+
203
+ return { entity };
204
+ };
205
+ ```
206
+
207
+ ### Exporting additional types
208
+
209
+ When the site component needs a named type for individual items (e.g., for helper functions or recursive rendering), export it from the loader:
210
+
211
+ ```ts
212
+ export type MyEntityItem = GQLMyIndexQuery["myEntities"][number];
213
+ export type LoadedData = Awaited<ReturnType<typeof loader>>;
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Visibility filtering
219
+
220
+ Entities with a `visible` field or `status` enum must never reach public users when hidden or unpublished. Apply visibility filtering in the loader — this is the last line of defense before data reaches the client.
221
+
222
+ ### Filter in the GraphQL query
223
+
224
+ The preferred approach is to use GraphQL filter arguments so hidden entities are never transferred over the wire:
225
+
226
+ ```ts
227
+ export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions<MyEntityBlockData>) => {
228
+ if (!blockData.id) return null;
229
+
230
+ const data = await graphQLFetch<GQLMyEntityBlockQuery, GQLMyEntityBlockQueryVariables>(
231
+ gql`
232
+ query MyEntityBlock($id: ID!, $filter: MyEntityFilter) {
233
+ myEntity(id: $id, filter: $filter) {
234
+ id
235
+ title
236
+ }
237
+ }
238
+ `,
239
+ {
240
+ id: blockData.id,
241
+ filter: { visible: { equal: true } },
242
+ },
243
+ );
244
+
245
+ return data.myEntity ?? null;
246
+ };
247
+ ```
248
+
249
+ For entities with a `status` enum:
250
+
251
+ ```ts
252
+ filter: {
253
+ status: {
254
+ equal: "Published";
255
+ }
256
+ }
257
+ ```
258
+
259
+ ### Filter after fetching
260
+
261
+ When the API does not support filter arguments on the query (e.g., `byIds` queries), filter in the loader after fetching:
262
+
263
+ ```ts
264
+ export const loader = async ({ blockData, graphQLFetch }: BlockLoaderOptions<MyEntityListBlockData>) => {
265
+ if (blockData.ids.length === 0) return [];
266
+
267
+ const data = await graphQLFetch<GQLMyEntityListBlockQuery, GQLMyEntityListBlockQueryVariables>(
268
+ gql`
269
+ query MyEntityListBlock($ids: [ID!]!) {
270
+ myEntitiesByIds(ids: $ids) {
271
+ id
272
+ title
273
+ visible
274
+ }
275
+ }
276
+ `,
277
+ { ids: blockData.ids },
278
+ );
279
+
280
+ return data.myEntitiesByIds.filter((entity) => entity.visible);
281
+ };
282
+ ```
283
+
284
+ ### Return `null` for hidden single entities
285
+
286
+ When loading a single entity that turns out to be hidden, return `null`. The site component must handle this gracefully (see "Site component" section below).
287
+
288
+ ---
289
+
290
+ ## Registration
291
+
292
+ Register the loader in the project's `recursivelyLoadBlockData.ts` wrapper (typically at `site/src/util/recursivelyLoadBlockData.ts`).
293
+
294
+ ### Steps
295
+
296
+ 1. Import the loader function.
297
+ 2. Add an entry to the `blockLoaders` record, keyed by the **block type name** (the name string passed to `createBlock` in the API, without the `Block` suffix in the key).
298
+
299
+ ```ts
300
+ import { type BlockLoader, type BlockLoaderDependencies, recursivelyLoadBlockData as cometRecursivelyLoadBlockData } from "@comet/site-nextjs";
301
+ import { type AllBlockNames } from "@src/blocks.generated";
302
+ import { loader as myEntityLoader } from "@src/path/to/blocks/MyEntityBlock.loader";
303
+
304
+ const blockLoaders: Partial<Record<AllBlockNames, BlockLoader>> = {
305
+ // existing loaders...
306
+ MyEntity: myEntityLoader,
307
+ };
308
+ ```
309
+
310
+ The key must match the block's type name exactly. This is the string used as the last argument in `createBlock(Data, Input, "MyEntity")` in the API layer.
311
+
312
+ ---
313
+
314
+ ## Site component
315
+
316
+ ### Typing
317
+
318
+ The component receives the loader's return value under `data.loaded`. Type it by intersecting the block data with `{ loaded: LoadedData }`:
319
+
320
+ ```tsx
321
+ import { type PropsWithData, withPreview } from "@comet/site-nextjs";
322
+ import { type MyEntityBlockData } from "@src/blocks.generated";
323
+
324
+ import { type LoadedData } from "./MyEntityBlock.loader";
325
+
326
+ export const MyEntityBlock = withPreview(
327
+ ({ data: { loaded } }: PropsWithData<MyEntityBlockData & { loaded: LoadedData }>) => {
328
+ if (!loaded) return null;
329
+
330
+ return <div>{loaded.title}</div>;
331
+ },
332
+ { label: "My Entity" },
333
+ );
334
+ ```
335
+
336
+ ### Null handling
337
+
338
+ When the loader returns `null` (entity missing, hidden, or unpublished), the component must handle it gracefully:
339
+
340
+ - **Single entity:** Check `!loaded` and return `null` (render nothing).
341
+ - **List of entities:** Check `loaded.length === 0` and return `null` or a fallback.
342
+
343
+ Never assume `loaded` contains data — the referenced entity may have been deleted or unpublished since the block was saved.
344
+
345
+ ### Destructuring the `loaded` property
346
+
347
+ Rename `loaded` to a descriptive variable during destructuring for readability:
348
+
349
+ ```tsx
350
+ ({ data: { loaded: product } }: PropsWithData<ProductBlockData & { loaded: LoadedData }>)
351
+
352
+ ({ data: { loaded: newsList } }: PropsWithData<NewsListBlockData & { loaded: LoadedData }>)
353
+ ```
354
+
355
+ ---
356
+
357
+ ## Checklist
358
+
359
+ Use this checklist when creating a block loader:
360
+
361
+ 1. Create `{BlockName}Block.loader.ts` alongside the site component.
362
+ 2. Export `loader` as a named `const` (async function).
363
+ 3. Export `LoadedData` type alias (`Awaited<ReturnType<typeof loader>>`).
364
+ 4. Handle empty/missing input (`!blockData.id` → return `null`, empty IDs array → return `[]`).
365
+ 5. Apply visibility filtering (prefer query-level filters; fall back to post-fetch filtering).
366
+ 6. Register the loader in `site/src/util/recursivelyLoadBlockData.ts`.
367
+ 7. Update the site component to type `data` as `BlockData & { loaded: LoadedData }`.
368
+ 8. Handle `null`/empty `loaded` in the component — render nothing or a fallback.
@@ -0,0 +1,210 @@
1
+ # Block Types Overview
2
+
3
+ Comet provides five core block types. Choose based on how content editors interact with the block.
4
+
5
+ ## Decision Guide
6
+
7
+ | Question | Block Type |
8
+ | --------------------------------------------- | ------------------- |
9
+ | Fixed set of named fields/child blocks? | **Composite block** |
10
+ | Multiple items of the same type (array)? | **List block** |
11
+ | Flexible content area with many block types? | **Blocks block** |
12
+ | Choose exactly one from several alternatives? | **One-of block** |
13
+ | Single block that can be toggled on/off? | **Optional block** |
14
+
15
+ **Common combinations:**
16
+
17
+ - A **list block** wraps a **composite block** as its item (e.g., `FeatureListBlock` wraps `FeatureItemBlock`).
18
+ - A **blocks block** contains multiple **composite blocks** and **list blocks** as supported blocks.
19
+ - A **composite block** may use a **one-of block** or **optional block** as a child property.
20
+ - A **one-of block** is commonly used inside a **composite block** for media selection or link types.
21
+
22
+ ---
23
+
24
+ ## Composite Block
25
+
26
+ A composite block groups a fixed set of named properties. Each property is either a **field** (string, number, enum, boolean) or a **child block**.
27
+
28
+ **When to use:** Most custom blocks are composite blocks. Use when the block has a known, fixed structure -- e.g., a teaser with a title, image, description, and link.
29
+
30
+ **API:** Define `BlockData` and `BlockInput` classes manually, then export with `createBlock`.
31
+
32
+ ```ts
33
+ class FeatureItemBlockData extends BlockData {
34
+ @ChildBlock(SvgImageBlock)
35
+ icon: BlockDataInterface;
36
+
37
+ @BlockField()
38
+ title: string;
39
+
40
+ @ChildBlock(RichTextBlock)
41
+ description: BlockDataInterface;
42
+ }
43
+
44
+ class FeatureItemBlockInput extends BlockInput {
45
+ @ChildBlockInput(SvgImageBlock)
46
+ icon: ExtractBlockInput<typeof SvgImageBlock>;
47
+
48
+ @BlockField()
49
+ @IsString()
50
+ title: string;
51
+
52
+ @ChildBlockInput(RichTextBlock)
53
+ description: ExtractBlockInput<typeof RichTextBlock>;
54
+
55
+ transformToBlockData(): FeatureItemBlockData {
56
+ return blockInputToData(FeatureItemBlockData, this);
57
+ }
58
+ }
59
+
60
+ export const FeatureItemBlock = createBlock(FeatureItemBlockData, FeatureItemBlockInput, "FeatureItem");
61
+ ```
62
+
63
+ **Admin:** Use `createCompositeBlock` to compose child blocks and fields.
64
+
65
+ ```tsx
66
+ export const FeatureItemBlock = createCompositeBlock(
67
+ {
68
+ name: "FeatureItem",
69
+ displayName: <FormattedMessage id="featureItemBlock.displayName" defaultMessage="Feature Item" />,
70
+ blocks: {
71
+ icon: {
72
+ block: SvgImageBlock,
73
+ title: <FormattedMessage id="featureItemBlock.icon" defaultMessage="Icon" />,
74
+ },
75
+ title: {
76
+ block: createCompositeBlockTextField({
77
+ label: <FormattedMessage id="featureItemBlock.title" defaultMessage="Title" />,
78
+ }),
79
+ },
80
+ description: {
81
+ block: RichTextBlock,
82
+ title: <FormattedMessage id="featureItemBlock.description" defaultMessage="Description" />,
83
+ },
84
+ },
85
+ },
86
+ (block) => {
87
+ block.previewContent = (state) => [{ type: "text", content: state.title }];
88
+ return block;
89
+ },
90
+ );
91
+ ```
92
+
93
+ **Site:** Plain React component with `PropsWithData` and `withPreview`.
94
+
95
+ ```tsx
96
+ export const FeatureItemBlock = withPreview(
97
+ ({ data: { icon, title, description } }: PropsWithData<FeatureItemBlockData>) => (
98
+ <div>
99
+ <SvgImageBlock data={icon} />
100
+ <strong>{title}</strong>
101
+ {hasRichTextBlockContent(description) && <RichTextBlock data={description} />}
102
+ </div>
103
+ ),
104
+ { label: "Feature Item" },
105
+ );
106
+ ```
107
+
108
+ ---
109
+
110
+ ## List Block
111
+
112
+ A list block wraps a **single child block type** and allows editors to add multiple instances of that block.
113
+
114
+ **When to use:** When the editor should be able to add, remove, and reorder multiple items of the same type -- e.g., a list of teasers, accordion items, slider slides.
115
+
116
+ **Important:** You cannot define a `BlockDataInterface` or `ExtractBlockInput` as an array. Always create a list block wrapping an item block to represent an array of blocks.
117
+
118
+ **API:** `createListBlock({ block: FeatureItemBlock }, "FeatureList")`
119
+
120
+ **Admin:** `createListBlock({ name: "FeatureList", block: FeatureItemBlock, itemName: ..., itemsName: ... })`
121
+
122
+ **Site:** `<ListBlock data={data} block={(props) => <FeatureItemBlock data={props} />} />`
123
+
124
+ ---
125
+
126
+ ## Blocks Block
127
+
128
+ A blocks block allows editors to **pick from multiple different block types** and add them in any order.
129
+
130
+ **When to use:** For flexible content areas -- e.g., `PageContentBlock` that supports rich text, images, teasers, accordions, and more. Use when you need two or more supported block types; for a single type use a list block instead.
131
+
132
+ **Key rule:** The keys in `supportedBlocks` must be **identical across all three layers** (API, Admin, Site). Use camelCase.
133
+
134
+ **API:** `createBlocksBlock({ supportedBlocks: { richText: ..., heading: ..., media: ... } }, "PageContent")`
135
+
136
+ **Admin:** `createBlocksBlock({ name: "PageContent", supportedBlocks: { ... } })`
137
+
138
+ **Site:** `<BlocksBlock data={data} supportedBlocks={supportedBlocks} />`
139
+
140
+ ---
141
+
142
+ ## One-of Block
143
+
144
+ A one-of block lets the editor **choose exactly one block type** from a set of options. Only the selected block is active.
145
+
146
+ **When to use:** When content requires choosing between mutually exclusive alternatives -- e.g., a `MediaBlock` that is either an image, a DAM video, a YouTube video, or a Vimeo video.
147
+
148
+ **Difference from blocks block:** A blocks block allows adding many items of different types in a list. A one-of block allows picking **one** active type.
149
+
150
+ **API:**
151
+
152
+ ```ts
153
+ export const MediaBlock = createOneOfBlock(
154
+ {
155
+ supportedBlocks: {
156
+ image: DamImageBlock,
157
+ damVideo: DamVideoBlock,
158
+ youTubeVideo: YouTubeVideoBlock,
159
+ },
160
+ },
161
+ "Media",
162
+ );
163
+ ```
164
+
165
+ **Admin:** Use `createOneOfBlock` with UI configuration. The `variant` option controls presentation:
166
+
167
+ - `"select"` (default) -- dropdown select field
168
+ - `"radio"` -- radio button group
169
+ - `"toggle"` -- toggle button group (best for 2--4 options)
170
+
171
+ Set `allowEmpty: false` to require a selection (no "None" option).
172
+
173
+ ```tsx
174
+ export const MediaBlock = createOneOfBlock({
175
+ supportedBlocks: { image: DamImageBlock, damVideo: DamVideoBlock, youTubeVideo: YouTubeVideoBlock },
176
+ name: "Media",
177
+ displayName: <FormattedMessage id="mediaBlock.displayName" defaultMessage="Media" />,
178
+ allowEmpty: false,
179
+ variant: "toggle",
180
+ category: BlockCategory.Media,
181
+ });
182
+ ```
183
+
184
+ **Site:** Use the `OneOfBlock` component with a module-level `SupportedBlocks` map.
185
+
186
+ ```tsx
187
+ const supportedBlocks: SupportedBlocks = {
188
+ image: (data) => <DamImageBlock data={data} />,
189
+ damVideo: (data) => <DamVideoBlock data={data} />,
190
+ youTubeVideo: (data) => <YouTubeVideoBlock data={data} />,
191
+ };
192
+
193
+ export const MediaBlock = withPreview(({ data }: PropsWithData<MediaBlockData>) => <OneOfBlock data={data} supportedBlocks={supportedBlocks} />, {
194
+ label: "Media",
195
+ });
196
+ ```
197
+
198
+ ---
199
+
200
+ ## Optional Block
201
+
202
+ An optional block wraps a **single block** and adds a visibility toggle.
203
+
204
+ **When to use:** When content is truly optional and should have an explicit on/off switch. Use sparingly -- prefer empty-state handling over optional blocks. Only use `createOptionalBlock` when there is a clear UX need for an explicit toggle.
205
+
206
+ **API:** `createOptionalBlock(RichTextBlock)` -- used as a child block inside a composite block.
207
+
208
+ **Admin:** `createOptionalBlock(RichTextBlock, { title: <FormattedMessage ... /> })`
209
+
210
+ **Site:** `<OptionalBlock data={content} block={(props) => <RichTextBlock data={props} />} />`