@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.
- package/LICENSE +24 -0
- package/package.json +19 -0
- package/rules/coding-guidelines/api-nestjs.instructions.md +107 -0
- package/rules/coding-guidelines/cdn.instructions.md +24 -0
- package/rules/coding-guidelines/general.instructions.md +30 -0
- package/rules/coding-guidelines/git.instructions.md +37 -0
- package/rules/coding-guidelines/kubernetes.instructions.md +59 -0
- package/rules/coding-guidelines/libraries.instructions.md +34 -0
- package/rules/coding-guidelines/naming.instructions.md +39 -0
- package/rules/coding-guidelines/postgresql.instructions.md +40 -0
- package/rules/coding-guidelines/react.instructions.md +102 -0
- package/rules/coding-guidelines/security.instructions.md +44 -0
- package/rules/coding-guidelines/styling.instructions.md +50 -0
- package/rules/coding-guidelines/typescript.instructions.md +50 -0
- package/skills/.gitkeep +0 -0
- package/skills/comet-block/SKILL.md +246 -0
- package/skills/comet-block/references/admin-patterns.md +192 -0
- package/skills/comet-block/references/api-patterns.md +183 -0
- package/skills/comet-block/references/block-loader.md +368 -0
- package/skills/comet-block/references/block-types.md +210 -0
- package/skills/comet-block/references/custom-block-field.md +266 -0
- package/skills/comet-block/references/fixtures.md +436 -0
- package/skills/comet-block/references/image.md +341 -0
- package/skills/comet-block/references/migration.md +597 -0
- package/skills/comet-block/references/registration.md +167 -0
- package/skills/comet-block/references/response-summary.md +102 -0
- package/skills/comet-block/references/rich-text.md +309 -0
- package/skills/comet-block/references/select.md +176 -0
- package/skills/comet-block/references/site-patterns.md +202 -0
- package/skills/comet-mail-react/SKILL.md +539 -0
- package/skills/comet-mail-react/references/components-and-theme.md +395 -0
- package/skills/comet-mail-react/references/layout-patterns.md +315 -0
- package/skills/comet-mail-react/references/styling-and-customization.md +306 -0
- package/skills/comet-minor-update/SKILL.md +191 -0
- 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} />} />`
|