Skip to content

Commit cd37356

Browse files
committed
Add isBlockWithItemOfType / isInlineBlockWithItemOfType guards
Narrow block and inlineBlock nodes by their underlying model's itemTypeId, with literal-preserving inference so callers don't need an explicit type parameter.
1 parent b9ed493 commit cd37356

2 files changed

Lines changed: 125 additions & 8 deletions

File tree

packages/utils/README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,31 @@ function isCdaStructuredTextValue(
170170
): object is CdaStructuredTextValue {}
171171
```
172172

173+
### Narrowing blocks by model
174+
175+
When your DAST tree has been fetched with typed responses (eg. the CMA client in `nested: true` mode), `block.item` / `inlineBlock.item` is a union of all possible block-model shapes. `isBlockWithItemOfType(itemTypeId)` and `isInlineBlockWithItemOfType(itemTypeId)` build a type guard that filters that union down to a single model and narrows the node accordingly.
176+
177+
```typescript
178+
import {
179+
findFirstNode,
180+
isBlockWithItemOfType,
181+
} from 'datocms-structured-text-utils';
182+
183+
const WARNING_BLOCK_TYPE_ID = 'abc123' as const;
184+
185+
const needle = findFirstNode(
186+
body.document,
187+
isBlockWithItemOfType(WARNING_BLOCK_TYPE_ID),
188+
);
189+
190+
if (needle) {
191+
// needle.node.item is narrowed to the Warning block-model shape
192+
console.log(needle.node.item.attributes.message);
193+
}
194+
```
195+
196+
Pass the `itemTypeId` as a literal (`as const` on pre-set constants) for narrowing to kick in. At runtime the guard walks `item.relationships.item_type.data.id`, so it works for any block item carrying that shapeCMA nested-mode responses and the object variants of request payloads. Bare string IDs (used in request payloads to reference unchanged blocks) are filtered out.
197+
173198
## Tree Manipulation Utilities
174199

175200
The package provides a comprehensive set of utilities for traversing, transforming, and querying structured text trees. All utilities support both synchronous and asynchronous operations, work with both document wrappers and plain nodes, and provide full TypeScript support with proper type narrowing.

packages/utils/src/guards.ts

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
Block,
2121
BlockId,
2222
Blockquote,
23+
CdaStructuredTextRecord,
2324
CdaStructuredTextValue,
2425
Code,
2526
Document,
@@ -34,7 +35,6 @@ import {
3435
Node,
3536
NodeType,
3637
Paragraph,
37-
Record as DatoCmsRecord,
3838
Root,
3939
Span,
4040
StructuredText,
@@ -120,6 +120,64 @@ export function isBlock<BlockItemType = BlockId, InlineBlockItemType = BlockId>(
120120
return node.type === blockNodeType;
121121
}
122122

123+
/**
124+
* Narrows a union of block item shapes to the member whose model
125+
* (`item_type`) matches `Id`.
126+
*
127+
* Any object carrying `relationships.item_type.data.id` is narrowable —
128+
* that covers items from `nested: true` responses as well as the object
129+
* variants of request payloads (updated / newly-created blocks). The bare
130+
* string IDs that may appear inside request payloads (to reference
131+
* existing, unchanged blocks) are filtered out of the result.
132+
*/
133+
export type NarrowBlockItemByItemType<T, Id extends string> = Extract<
134+
T,
135+
{ relationships: { item_type: { data: { type: 'item_type'; id: Id } } } }
136+
>;
137+
138+
/**
139+
* Builds a type guard that narrows a `block` node to the variant whose
140+
* `item` belongs to a specific model.
141+
*
142+
* Call it with the block's `itemTypeId` literal — the ID generic is
143+
* inferred from the argument, so no explicit type parameter is needed.
144+
* Usable directly with `findFirstNode` / `findAllNodes` / `Array#filter`
145+
* over block-bearing DAST trees.
146+
*
147+
* For the literal `Id` to be preserved (and narrowing to work), the
148+
* argument must be typed as a literal — use `as const` on pre-set ID
149+
* constants.
150+
*
151+
* @example
152+
* ```ts
153+
* const needle = findFirstNode(
154+
* body.document,
155+
* isBlockWithItemOfType(WARNING_BLOCK_TYPE_ID),
156+
* );
157+
* if (needle) {
158+
* needle.node.item; // narrowed to the Warning item shape
159+
* }
160+
* ```
161+
*/
162+
function itemHasItemTypeId(item: unknown, itemTypeId: string): boolean {
163+
if (typeof item !== 'object' || item === null) return false;
164+
const relationships = (item as { relationships?: unknown }).relationships;
165+
if (typeof relationships !== 'object' || relationships === null) return false;
166+
const itemType = (relationships as { item_type?: unknown }).item_type;
167+
if (typeof itemType !== 'object' || itemType === null) return false;
168+
const data = (itemType as { data?: unknown }).data;
169+
if (typeof data !== 'object' || data === null) return false;
170+
return (data as { id?: unknown }).id === itemTypeId;
171+
}
172+
173+
export function isBlockWithItemOfType<Id extends string>(itemTypeId: Id) {
174+
return <BlockItemType = BlockId, InlineBlockItemType = BlockId>(
175+
node: Node<BlockItemType, InlineBlockItemType>,
176+
): node is Block<NarrowBlockItemByItemType<BlockItemType, Id>> =>
177+
node.type === blockNodeType &&
178+
itemHasItemTypeId((node as Block<BlockItemType>).item, itemTypeId);
179+
}
180+
123181
export function isInlineBlock<
124182
BlockItemType = BlockId,
125183
InlineBlockItemType = BlockId
@@ -129,6 +187,40 @@ export function isInlineBlock<
129187
return node.type === inlineBlockNodeType;
130188
}
131189

190+
/**
191+
* Builds a type guard that narrows an `inlineBlock` node to the variant
192+
* whose `item` belongs to a specific model.
193+
*
194+
* Mirrors {@link isBlockWithItemOfType} for inline blocks. Call it with
195+
* the block's `itemTypeId` literal — the ID generic is inferred from the
196+
* argument, so no explicit type parameter is needed.
197+
*
198+
* For the literal `Id` to be preserved (and narrowing to work), the
199+
* argument must be typed as a literal — use `as const` on pre-set ID
200+
* constants.
201+
*
202+
* @example
203+
* ```ts
204+
* const needle = findFirstNode(
205+
* body.document,
206+
* isInlineBlockWithItemOfType(CALLOUT_BLOCK_TYPE_ID),
207+
* );
208+
* if (needle) {
209+
* needle.node.item; // narrowed to the Callout item shape
210+
* }
211+
* ```
212+
*/
213+
export function isInlineBlockWithItemOfType<Id extends string>(itemTypeId: Id) {
214+
return <BlockItemType = BlockId, InlineBlockItemType = BlockId>(
215+
node: Node<BlockItemType, InlineBlockItemType>,
216+
): node is InlineBlock<NarrowBlockItemByItemType<InlineBlockItemType, Id>> =>
217+
node.type === inlineBlockNodeType &&
218+
itemHasItemTypeId(
219+
(node as InlineBlock<InlineBlockItemType>).item,
220+
itemTypeId,
221+
);
222+
}
223+
132224
export function isCode<BlockItemType = BlockId, InlineBlockItemType = BlockId>(
133225
node: Node<BlockItemType, InlineBlockItemType>,
134226
): node is Code {
@@ -182,9 +274,9 @@ export function isNode<BlockItemType = BlockId, InlineBlockItemType = BlockId>(
182274
}
183275

184276
export function isCdaStructuredTextValue<
185-
BlockRecord extends DatoCmsRecord,
186-
LinkRecord extends DatoCmsRecord,
187-
InlineBlockRecord extends DatoCmsRecord
277+
BlockRecord extends CdaStructuredTextRecord,
278+
LinkRecord extends CdaStructuredTextRecord,
279+
InlineBlockRecord extends CdaStructuredTextRecord
188280
>(
189281
obj: unknown,
190282
): obj is CdaStructuredTextValue<BlockRecord, LinkRecord, InlineBlockRecord> {
@@ -195,9 +287,9 @@ export function isCdaStructuredTextValue<
195287
* @deprecated Use isCdaStructuredTextValue instead
196288
*/
197289
export function isStructuredText<
198-
BlockRecord extends DatoCmsRecord,
199-
LinkRecord extends DatoCmsRecord,
200-
InlineBlockRecord extends DatoCmsRecord
290+
BlockRecord extends CdaStructuredTextRecord,
291+
LinkRecord extends CdaStructuredTextRecord,
292+
InlineBlockRecord extends CdaStructuredTextRecord
201293
>(
202294
obj: unknown,
203295
): obj is StructuredText<BlockRecord, LinkRecord, InlineBlockRecord> {
@@ -222,7 +314,7 @@ export function isEmptyDocument(obj: unknown): boolean {
222314
}
223315

224316
const document =
225-
isStructuredText(obj) && isDocument(obj.value)
317+
isCdaStructuredTextValue(obj) && isDocument(obj.value)
226318
? obj.value
227319
: isDocument(obj)
228320
? obj

0 commit comments

Comments
 (0)