@financial-times/content-tree 0.1.0

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/SPEC.md ADDED
@@ -0,0 +1,867 @@
1
+ # Content Tree Spec
2
+
3
+ ## Abstract Types
4
+
5
+ These abstract helper types define special types a [Parent](#parent) can use as
6
+ [children][term-child].
7
+
8
+ ### `LayoutWidth`
9
+
10
+ ```ts
11
+ type LayoutWidth =
12
+ | "auto"
13
+ | "in-line"
14
+ | "inset-left"
15
+ | "inset-right"
16
+ | "full-bleed"
17
+ | "full-grid"
18
+ | "mid-grid"
19
+ | "full-width"
20
+ ```
21
+
22
+ `LayoutWidth` defines how the component should be presented in the article page according to the column layout system.
23
+
24
+
25
+ ## Core Nodes
26
+
27
+ ### `Node`
28
+
29
+ ```ts
30
+ interface Node {
31
+ type: string
32
+ data?: any
33
+ }
34
+ ```
35
+
36
+ The abstract node. The data field is for internal implementation information and
37
+ will never be defined in the content-tree spec.
38
+
39
+ ### `Parent`
40
+
41
+ ```ts
42
+ interface Parent extends Node {
43
+ children: Node[]
44
+ }
45
+ ```
46
+
47
+ **Parent** (**[UnistParent][term-parent]**) represents a node in content-tree
48
+ containing other nodes (said to be _[children][term-child]_).
49
+
50
+ Its content is limited to only other content-tree content.
51
+
52
+ ### `Root`
53
+
54
+ ```ts
55
+ interface Root extends Node {
56
+ type: "root"
57
+ body: Body
58
+ }
59
+ ```
60
+
61
+ **Root** (**[Parent][term-parent]**) represents the root of a content-tree.
62
+
63
+ **Root** can be used as the _[root][term-root]_ of a _[tree][term-tree]_.
64
+
65
+
66
+ ## Containers
67
+
68
+ A Container is a node which houses a distinct block of editorial content. These will typically have children containing any number of component nodes. They can be published and validated as independent fields in the Content API.
69
+
70
+ Examples: `body`, `topper`
71
+
72
+ ### `Body`
73
+
74
+ ```ts
75
+ interface Body extends Parent {
76
+ type: "body"
77
+ version: number
78
+ children: BodyBlock[]
79
+ }
80
+ ```
81
+ #### BodyBlock
82
+
83
+ ```ts
84
+ type BodyBlock =
85
+ | FormattingBlock
86
+ | StoryBlock
87
+ ```
88
+
89
+ `BodyBlock` nodes are the only things that are valid as the top level of a `Body`.
90
+
91
+ **Body** (**[Parent][term-parent]**) represents the body of an article.
92
+
93
+ (note: `bodyTree` is just this part)
94
+
95
+ ## Formatting Blocks
96
+
97
+ ### FormattingBlock
98
+
99
+ ```ts
100
+ type FormattingBlock =
101
+ | Paragraph
102
+ | Heading
103
+ | List
104
+ | Blockquote
105
+ | ThematicBreak
106
+ | Text
107
+ ```
108
+
109
+ `FormattingBlock` nodes contains only text-structured blocks used for formatting textual content.
110
+
111
+
112
+ ### `Text`
113
+
114
+ ```ts
115
+ interface Text extends Node {
116
+ type: "text"
117
+ value: string
118
+ }
119
+ ```
120
+
121
+
122
+ ### `Phrasing`
123
+
124
+ ```ts
125
+ type Phrasing = Text | Break | Strong | Emphasis | Strikethrough | Link | FindOutMoreLink
126
+ ```
127
+
128
+ A phrasing node cannot have ancestor of the same type.
129
+
130
+ i.e. a Strong will never be inside another Strong, or inside any other node that
131
+ is inside a Strong.
132
+
133
+ **Text** (**[Literal][term-literal]**) represents text.
134
+
135
+ ### `Break`
136
+
137
+ ```ts
138
+ interface Break extends Node {
139
+ type: "break"
140
+ }
141
+ ```
142
+
143
+ **Break** Node represents a break in the text, such as in a poem.
144
+
145
+ _Non-normative note: this would normally be represented by a `<br>` in the
146
+ html._
147
+
148
+ ### `ThematicBreak`
149
+
150
+ ```ts
151
+ interface ThematicBreak extends Node {
152
+ type: "thematic-break"
153
+ }
154
+ ```
155
+
156
+ **ThematicBreak** Node represents a break in the text, such as in a shift of
157
+ topic within a section.
158
+
159
+ _Non-normative note: this would be represented by an `<hr>` in the html._
160
+
161
+ ### `Paragraph`
162
+
163
+ ```ts
164
+ interface Paragraph extends Parent {
165
+ type: "paragraph"
166
+ children: Phrasing[]
167
+ }
168
+ ```
169
+
170
+ Paragraph represents a unit of text.
171
+
172
+ ### `Heading`
173
+
174
+ ```ts
175
+ interface Heading extends Parent {
176
+ type: "heading"
177
+ children: Text[]
178
+ level: "chapter" | "subheading" | "label"
179
+ fragmentIdentifier?: string
180
+ }
181
+ ```
182
+
183
+ **Heading** represents a unit of text that marks the beginning of an article
184
+ section.
185
+
186
+ ### `Strong`
187
+
188
+ ```ts
189
+ interface Strong extends Parent {
190
+ type: "strong"
191
+ children: Phrasing[]
192
+ }
193
+ ```
194
+
195
+ **Strong** represents contents with strong importance, seriousness or urgency.
196
+
197
+ ### `Emphasis`
198
+
199
+ ```ts
200
+ interface Emphasis extends Parent {
201
+ type: "emphasis"
202
+ children: Phrasing[]
203
+ }
204
+ ```
205
+
206
+ **Emphasis** represents stressed emphasis of its contents.
207
+
208
+ ### `Strikethrough`
209
+
210
+ ```ts
211
+ interface Strikethrough extends Parent {
212
+ type: "strikethrough"
213
+ children: Phrasing[]
214
+ }
215
+ ```
216
+
217
+ **Strikethrough** represents a piece of text that has been stricken.
218
+
219
+ ### `Link`
220
+
221
+ ```ts
222
+ interface Link extends Parent {
223
+ type: "link"
224
+ url: string
225
+ title: string
226
+ children: Phrasing[]
227
+ }
228
+ ```
229
+
230
+ **Link** represents a hyperlink.
231
+
232
+
233
+ ### `FindOutMoreLink`
234
+
235
+ ```ts
236
+ interface FindOutMoreLink extends Parent {
237
+ type: "find-out-more-link"
238
+ url: string
239
+ title: string
240
+ children: Phrasing[]
241
+ }
242
+ ```
243
+
244
+ **FindOutMoreLink** represents a type of link for onward journey.
245
+
246
+ ### `List`
247
+
248
+ ```ts
249
+ interface List extends Parent {
250
+ type: "list"
251
+ ordered: boolean
252
+ children: ListItem[]
253
+ }
254
+ ```
255
+
256
+ **List** represents a list of items.
257
+
258
+ ### `ListItem`
259
+
260
+ ```ts
261
+ interface ListItem extends Parent {
262
+ type: "list-item"
263
+ children: (Paragraph | Phrasing)[]
264
+ }
265
+ ```
266
+
267
+ ### `Blockquote`
268
+
269
+ ```ts
270
+ interface Blockquote extends Parent {
271
+ type: "blockquote"
272
+ children: (Paragraph | Phrasing)[]
273
+ }
274
+ ```
275
+
276
+ **Blockquote** represents a quotation.
277
+
278
+ ## StoryBlocks
279
+
280
+ ### `StoryBlock`
281
+
282
+ ```ts
283
+ type StoryBlock =
284
+ | ImageSet
285
+ | Flourish
286
+ | BigNumber
287
+ | CustomCodeComponent
288
+ | Layout
289
+ | Pullquote
290
+ | ScrollyBlock
291
+ | Table
292
+ | Recommended
293
+ | RecommendedList
294
+ | Tweet
295
+ | Video
296
+ | YoutubeVideo
297
+ | Timeline
298
+ | ImagePair
299
+ | InNumbers
300
+ | Definition
301
+ | InfoBox
302
+ | InfoPair
303
+ ```
304
+
305
+ `StoryBlock` nodes are things that can be inserted into an article body.
306
+
307
+ ### `Pullquote`
308
+
309
+ ```ts
310
+ interface Pullquote extends Node {
311
+ type: "pullquote"
312
+ text: string
313
+ source?: string
314
+ }
315
+ ```
316
+
317
+ **Pullquote** represents a brief quotation taken from the main text of an
318
+ article.
319
+
320
+ _non normative note:_ the reason this is string properties and not children is
321
+ that it is more confusing if a pullquote falls back to text than if it
322
+ doesn't. The text is taken from elsewhere in the article.
323
+
324
+
325
+ ### `ImageSet`
326
+
327
+ ```ts
328
+ interface ImageSet extends Node {
329
+ type: "image-set"
330
+ id: string
331
+ external picture: ImageSetPicture
332
+ fragmentIdentifier?: string
333
+ }
334
+ ```
335
+
336
+ #### Image types
337
+
338
+ ##### `ImageSetPicture`
339
+
340
+ ```ts
341
+ type ImageSetPicture = {
342
+ layoutWidth: string
343
+ imageType: "image" | "graphic"
344
+ alt: string
345
+ caption: string
346
+ credit: string
347
+ images: Image[]
348
+ fallbackImage: Image
349
+ }
350
+ ```
351
+
352
+ `ImageSetPicture` defines the data associated with an [ImageSet](#ImageSet)
353
+
354
+ ##### `Image`
355
+
356
+ ```ts
357
+ type Image = {
358
+ id: string
359
+ width: number
360
+ height: number
361
+ format:
362
+ | "desktop"
363
+ | "mobile"
364
+ | "square"
365
+ | "square-ftedit"
366
+ | "standard"
367
+ | "wide"
368
+ | "standard-inline"
369
+ url: string
370
+ sourceSet?: ImageSource[]
371
+ }
372
+ ```
373
+
374
+ `Image` defines a single use-case of a Picture[#ImageSetPicture].
375
+
376
+ ### `ImageSource`
377
+
378
+ ```ts
379
+ type ImageSource = {
380
+ url: string
381
+ width: number
382
+ dpr: number
383
+ }
384
+ ```
385
+
386
+ **ImageSource** defines a single resource for an [image](#image).
387
+
388
+
389
+ ### `Recommended`
390
+
391
+
392
+ ```ts
393
+ interface Recommended extends Node {
394
+ type: "recommended"
395
+ id: string
396
+ heading?: string
397
+ teaserTitleOverride?: string
398
+ external teaser: Teaser
399
+ }
400
+ ```
401
+
402
+ - Recommended represents a reference to an FT content that has been recommended
403
+ by editorial.
404
+ - The `heading`, when present, is used where the purpose of the link is more
405
+ specific than being "Recommended" (an example might be "In depth")
406
+ - The `teaserTitleOverride`, when present, is used in place of the content title
407
+ of the link.
408
+
409
+ _non normative note:_ historically, recommended links used to be a list of up to
410
+ three content items. Testing later showed that having one more prominent link
411
+ was more engaging. Only use `RecommendedList` if you explicitly need to display multiple links.
412
+
413
+
414
+ ### `RecommendedList`
415
+
416
+
417
+ ```ts
418
+ interface RecommendedList extends Node {
419
+ type: "recommended-list";
420
+ heading?: string;
421
+ children: Recommended[];
422
+ }
423
+ ```
424
+
425
+ - RecommendedList represents a collection of Recommended items selected by editorial.
426
+ - The `heading`, when present, is used where the purpose of the link is more
427
+ specific than being "Related Content"
428
+
429
+ #### Teaser types
430
+
431
+ These types were extracted from x-dash's
432
+ [x-teaser](https://github.com/Financial-Times/x-dash/blob/3408c268/components/x-teaser/Props.d.ts).
433
+
434
+ ```ts
435
+ type TeaserConcept = {
436
+ apiUrl: string
437
+ directType: string
438
+ id: string
439
+ predicate: string
440
+ prefLabel: string
441
+ type: string
442
+ types: string[]
443
+ url: string
444
+ }
445
+
446
+ type Teaser = {
447
+ id: string
448
+ url: string
449
+ type:
450
+ | "article"
451
+ | "video"
452
+ | "podcast"
453
+ | "audio"
454
+ | "package"
455
+ | "liveblog"
456
+ | "promoted-content"
457
+ | "paid-post"
458
+ title: string
459
+ publishedDate: string
460
+ firstPublishedDate: string
461
+ metaLink?: TeaserConcept
462
+ metaAltLink?: TeaserConcept
463
+ metaPrefixText?: string
464
+ metaSuffixText?: string
465
+ indicators: {
466
+ accessLevel: "premium" | "subscribed" | "registered" | "free"
467
+ isOpinion?: boolean
468
+ isColumn?: boolean
469
+ isPodcast?: boolean
470
+ isEditorsChoice?: boolean
471
+ isExclusive?: boolean
472
+ isScoop?: boolean
473
+ }
474
+ image: {
475
+ url: string
476
+ width: number
477
+ height: number
478
+ }
479
+ clientName?: string
480
+ }
481
+ ```
482
+
483
+
484
+ ### `Tweet`
485
+
486
+ ```ts
487
+ interface Tweet extends Node {
488
+ id: string
489
+ type: "tweet"
490
+ external html: string
491
+ }
492
+ ```
493
+
494
+ **Tweet** represents a tweet.
495
+
496
+ ### `Flourish`
497
+
498
+ ```ts
499
+
500
+ type FlourishLayoutWidth = Extract<LayoutWidth, "full-grid" | "in-line">
501
+
502
+ interface Flourish extends Node {
503
+ type: "flourish"
504
+ id: string
505
+ layoutWidth: FlourishLayoutWidth
506
+ flourishType: string
507
+ description?: string
508
+ timestamp?: string
509
+ external fallbackImage?: Image
510
+ fragmentIdentifier?: string
511
+ }
512
+ ```
513
+
514
+ **Flourish** represents a flourish chart.
515
+
516
+ ### `BigNumber`
517
+
518
+ ```ts
519
+ interface BigNumber extends Node {
520
+ type: "big-number"
521
+ number: string
522
+ description: string
523
+ }
524
+ ```
525
+
526
+ **BigNumber** represents a big number.
527
+
528
+ ### `Video`
529
+
530
+ ```ts
531
+ interface Video extends Node {
532
+ type: "video"
533
+ id: string
534
+ external title: string
535
+ }
536
+ ```
537
+
538
+ **Video** represents an FT video referenced by a URL.
539
+
540
+ The `title` can be obtained by fetching the Video from the content API.
541
+
542
+ TODO: Figure out how Clips work, how they are different?
543
+
544
+ ### `YoutubeVideo`
545
+
546
+ ```ts
547
+ interface YoutubeVideo extends Node {
548
+ type: "youtube-video"
549
+ url: string
550
+ }
551
+ ```
552
+
553
+ **YoutubeVideo** represents a video referenced by a Youtube URL.
554
+
555
+ ### `ScrollyBlock`
556
+
557
+ ```ts
558
+ interface ScrollyBlock extends Parent {
559
+ type: "scrolly-block"
560
+ theme: "sans" | "serif"
561
+ children: ScrollySection[]
562
+ }
563
+ ```
564
+
565
+ **ScrollyBlock** represents a block for telling stories through scroll position.
566
+
567
+ ### `ScrollySection`
568
+
569
+ ```ts
570
+ interface ScrollySection extends Parent {
571
+ type: "scrolly-section"
572
+ display: "dark-background" | "light-background"
573
+ noBox?: true,
574
+ position: "left" | "center" | "right"
575
+ transition?: "delay-before" | "delay-after"
576
+ children: [ScrollyImage, ...ScrollyCopy[]]
577
+ }
578
+ ```
579
+
580
+ **ScrollySection** represents a section of a [ScrollyBlock](#scrollyblock)
581
+
582
+ ### `ScrollyImage`
583
+
584
+ ```ts
585
+ interface ScrollyImage extends Node {
586
+ type: "scrolly-image"
587
+ id: string
588
+ external picture: ImageSetPicture
589
+ }
590
+ ```
591
+
592
+ **ScrollyImage** represents an image contained in a [ScrollySection](#scrollysection)
593
+
594
+ ### `ScrollyCopy`
595
+
596
+ ```ts
597
+ interface ScrollyCopy extends Parent {
598
+ type: "scrolly-copy"
599
+ children: (ScrollyHeading | Paragraph)[]
600
+ }
601
+ ```
602
+
603
+ **ScrollyCopy** represents a collection of **ScrollyHeading** or **Paragraph** nodes.
604
+
605
+ ```ts
606
+ interface ScrollyHeading extends Parent {
607
+ type: "scrolly-heading"
608
+ level: "chapter" | "heading" | "subheading"
609
+ children: Text[]
610
+ }
611
+ ```
612
+
613
+ **ScrollyHeading** represents a heading within a **ScrollyCopy** block.
614
+
615
+ ### `Layout`
616
+
617
+ ```ts
618
+ interface Layout extends Parent {
619
+ type: "layout"
620
+ layoutName: "auto" | "card" | "timeline"
621
+ layoutWidth: string
622
+ children: [Heading, LayoutImage, ...LayoutSlot[]] | [Heading, ...LayoutSlot[]] | LayoutSlot[]
623
+ }
624
+ ```
625
+
626
+ **Layout** nodes are a generic component used to display a combination of other
627
+ nodes (headings, images and paragraphs) in a visually distinctive way.
628
+
629
+ The `layoutName` acts as a sort of theme for the component.
630
+
631
+ ### `LayoutSlot`
632
+
633
+
634
+ ```ts
635
+ interface LayoutSlot extends Parent {
636
+ type: "layout-slot"
637
+ children: (Heading | Paragraph | LayoutImage)[]
638
+ }
639
+ ```
640
+
641
+ A **Layout** can contain a number of **LayoutSlots**, which can be arranged
642
+ visually
643
+
644
+ _Non-normative note_: typically these would be displayed as flex items, so they
645
+ would appear next to each other taking up equal width.
646
+
647
+ ### `LayoutImage`
648
+
649
+ ```ts
650
+ interface LayoutImage extends Node {
651
+ type: "layout-image"
652
+ id: string
653
+ alt: string
654
+ caption: string
655
+ credit: string
656
+ external picture: ImageSetPicture
657
+ }
658
+ ```
659
+
660
+ - **LayoutImage** is a workaround to handle pre-existing articles that were
661
+ published using `<img>` tags rather than `<ft-content>` images. The reason for
662
+ this was that in the bodyXML, layout nodes were inside an `<experimental>`
663
+ tag, and that didn't support publishing `<ft-content>`.
664
+
665
+ ### `Table`
666
+
667
+ ```ts
668
+ type TableColumnSettings = {
669
+ hideOnMobile: boolean
670
+ sortable: boolean
671
+ sortType: 'text' | 'number' | 'date' | 'currency' | 'percent'
672
+ }
673
+
674
+ type TableLayoutWidth = Extract<LayoutWidth,
675
+ | 'auto'
676
+ | 'full-grid'
677
+ | 'inset-left'
678
+ | 'inset-right'
679
+ | 'full-bleed'>
680
+
681
+
682
+ interface TableCaption extends Parent {
683
+ type: 'table-caption'
684
+ children: Phrasing[]
685
+ }
686
+
687
+ interface TableCell extends Parent {
688
+ type: 'table-cell'
689
+ heading?: boolean
690
+ columnSpan?: number
691
+ rowSpan?: number
692
+ children: Phrasing[]
693
+ }
694
+
695
+ interface TableRow extends Parent {
696
+ type: 'table-row'
697
+ children: TableCell[]
698
+ }
699
+
700
+ interface TableBody extends Parent {
701
+ type: 'table-body'
702
+ children: TableRow[]
703
+ }
704
+
705
+ interface TableFooter extends Parent {
706
+ type: 'table-footer'
707
+ children: Phrasing[]
708
+ }
709
+
710
+ interface Table extends Parent {
711
+ type: 'table'
712
+ stripes: boolean
713
+ compact: boolean
714
+ layoutWidth: TableLayoutWidth
715
+ collapseAfterHowManyRows?: number
716
+ responsiveStyle: 'overflow' | 'flat' | 'scroll'
717
+ children: [TableCaption, TableBody, TableFooter] | [TableCaption, TableBody] | [TableBody, TableFooter] | [TableBody]
718
+ columnSettings: TableColumnSettings[]
719
+ }
720
+ ```
721
+
722
+ **Table** represents 2d data.
723
+
724
+ ### CustomCodeComponent
725
+
726
+ ```ts
727
+ type CustomCodeComponentAttributes = {
728
+ [key: string]: string | boolean | undefined
729
+ }
730
+
731
+ interface CustomCodeComponent extends Node {
732
+ /** Component type */
733
+ type: "custom-code-component"
734
+ /** Id taken from the CAPI url */
735
+ id: string
736
+ /** How the component should be presented in the article page according to the column layout system */
737
+ layoutWidth: LayoutWidth
738
+ /** Repository for the code of the component in the format "[github org]/[github repo]/[component name]". */
739
+ external path: string
740
+ /** Semantic version of the code of the component, e.g. "^0.3.5". */
741
+ external versionRange: string
742
+ /** Last date-time when the attributes for this block were modified, in ISO-8601 format. */
743
+ external attributesLastModified: string
744
+ /** Configuration data to be passed to the component. */
745
+ external attributes: CustomCodeComponentAttributes
746
+ }
747
+ ```
748
+
749
+ - The **CustomCodeComponent*** allows for more experimental forms of journalism, allowing editors to provide properties via Spark.
750
+ - The component itself lives off-platform, and an example might be a git repository with a standard structure. This structure would include the rendering instructions, and the data structure that is expected to be provided to the component for it to render if necessary.
751
+ - The basic interface in Spark to make reference to this system above (eg. the git repo URL or a public S3 bucket), and provide some data for it if necessary. This will be the Custom Component storyblock.
752
+ - The data Spark receives from entering a specific ID will be used to render dynamic fields (the `attributes`).
753
+
754
+ ### ImagePair
755
+
756
+ ```ts
757
+ interface ImagePair extends Parent {
758
+ type: 'image-pair'
759
+ children: [ImageSet, ImageSet]
760
+ }
761
+ ```
762
+
763
+ **ImagePair** is a set of two images
764
+
765
+ ### Timeline
766
+
767
+ ```ts
768
+ /**
769
+ * Timeline nodes display a timeline of events in arbitrary order.
770
+ */
771
+ interface Timeline extends Parent {
772
+ type: "timeline"
773
+ /** The title for the timeline */
774
+ title: string
775
+ children: TimelineEvent[]
776
+ }
777
+
778
+ /**
779
+ * TimelineEvent is the representation of a single event in a Timeline.
780
+ */
781
+ interface TimelineEvent extends Parent {
782
+ type: "timeline-event"
783
+ /** The title of the event */
784
+ title: string
785
+ /** Any combination of paragraphs and image sets */
786
+ children: (Paragraph | ImageSet)[];
787
+ }
788
+ ```
789
+
790
+ ### InNumbers
791
+
792
+ ```ts
793
+ /**
794
+ * A definition has a term and a related description. It is used to describe a term.
795
+ */
796
+ interface Definition extends Node {
797
+ type: "definition"
798
+ term: string
799
+ description: string
800
+ }
801
+
802
+ /**
803
+ * InNumbers represents a set of numbers with related descriptions.
804
+ */
805
+ interface InNumbers extends Parent {
806
+ type: "in-numbers"
807
+ /** The title for the InNumbers */
808
+ title?: string
809
+ children: [Definition, Definition, Definition]
810
+ }
811
+ ```
812
+
813
+ ### Card
814
+
815
+ ```ts
816
+ /** Allowed children for a card
817
+ */
818
+ type CardChildren = ImageSet | Exclude<FormattingBlock, Heading>
819
+ /**
820
+ * A card describes a subject with images and text
821
+ */
822
+ interface Card extends Parent {
823
+ type: "card"
824
+ /** The title of this card */
825
+ title?: string
826
+ children: CardChildren[]
827
+ }
828
+ ```
829
+
830
+ ### InfoBox
831
+
832
+ ```ts
833
+ /**
834
+ * Allowed layout widths for an InfoBox.
835
+ */
836
+ type InfoBoxLayoutWidth = Extract<LayoutWidth, "in-line" | "inset-left">
837
+ /**
838
+ * An info box describes a subject via a single card
839
+ */
840
+ interface InfoBox extends Parent {
841
+ type: "info-box"
842
+ /** The layout width supported by this node */
843
+ layoutWidth: InfoBoxLayoutWidth
844
+ children: [Card]
845
+ }
846
+ ```
847
+
848
+ ### InfoPair
849
+
850
+ ```ts
851
+ /**
852
+ * InfoPair provides exactly two cards.
853
+ */
854
+ interface InfoPair extends Parent {
855
+ type: "info-pair"
856
+ /** The title of the info pair */
857
+ title?: string
858
+ children: [Card, Card]
859
+ }
860
+ ```
861
+
862
+
863
+
864
+
865
+
866
+
867
+