import React, { useContext, JSXElementConstructor, ReactNode, createContext, } from 'react' import type { ContentTree } from '@financial-times/content-tree' import Recommended from '../content-tree/Recommended' import RecommendedList from '../content-tree/RecommendedList' import ImageSet, { FallbackImage } from '../content-tree/ImageSet' import { Layout, LayoutSlot } from '../content-tree/Layout' import Pullquote from '../content-tree/Pullquote' import Tweet from '../content-tree/Tweet' import Flourish from '../content-tree/Flourish' import Heading from '../content-tree/Heading' import BigNumber from '../content-tree/BigNumber' import Video from '../content-tree/Video' import Table from '../content-tree/Table' import Clip from '../content-tree/Clip' import Paragraph from '../content-tree/Paragraph' import TableBody from '../content-tree/Table/TableBody' import { TableCell } from '../content-tree/Table/TableCell' import { ScrollyBlock, ScrollyCopy, ScrollyHeading, ScrollySection, } from '../content-tree/Scrollytelling' import { ScrollyImage } from '../content-tree/Scrollytelling/ScrollyImage' import YoutubeVideo from '../content-tree/YoutubeVideo' import CustomCodeComponent from '../content-tree/CustomCodeComponent' import ImagePair from '../content-tree/ImagePair' import Timeline from '../content-tree/Timeline' import TimelineEvent from '../content-tree/Timeline/TimelineEvent' import Card from '../content-tree/Card' import InfoBox from '../content-tree/InfoBox' import InfoPair from '../content-tree/InfoPair' import InNumbers, { Definition } from '../content-tree/InNumbers' import { List, ListItem, Link, Blockquote, Cite, LineBreak, HorizontalRule, Emphasis, Strong, Strikethrough, TableCaption, TableFooter, TableRow, CccFallbackText, } from './BasicComponents' import type { StructuredContentFragment, ReferenceMapping, } from '@financial-times/cp-content-pipeline-client' import type { ContentTreeWorkarounds } from '@financial-times/cp-content-pipeline-schema' import type { mapNodeToReference } from '@financial-times/cp-content-pipeline-schema/lib/resolvers/content-tree/references' import type { ContentProps } from '../types' import RichTextContext from './context' // A helper type that takes a content tree type identifier and maps it to the // object that is returned in an accompanying reference type mapComponentToReference = T extends keyof typeof mapNodeToReference ? (typeof mapNodeToReference)[T] extends keyof ReferenceMapping ? ReferenceMapping[(typeof mapNodeToReference)[T]] : never : Record // Partial record of content tree nodes and their associated reference types export type RichTextComponentMapRecord = { [T in | ContentTreeWorkarounds.AnyNode | { type: 'fallback' } as T['type']]?: JSXElementConstructor< React.PropsWithChildren< T['type'] extends 'fallback' ? unknown : ContentProps< T & mapComponentToReference> > > > } export const ComponentsContext = createContext< RichTextComponentMapRecord | undefined >({}) type WithOptionalProperty = Pick, K> & Omit export type RichTextProps = { // the component using RichText might not have queried for references, so make it optional structuredContent: WithOptionalProperty< StructuredContentFragment, 'references' > components?: RichTextComponentMapRecord } /* This will be the default set of components we provide, that most * clients can use to render a workable article page */ const componentMap: RichTextComponentMapRecord = { fallback: (props) => props.children, 'big-number': BigNumber, cite: Cite, blockquote: Blockquote, break: LineBreak, 'ccc-fallback-text': CccFallbackText, 'clip-set': Clip, clip: Clip, 'custom-code-component': CustomCodeComponent, emphasis: Emphasis, flourish: Flourish, heading: Heading, 'image-pair': ImagePair, 'image-set': ImageSet, 'layout-image': ImageSet, 'layout-slot': LayoutSlot, layout: Layout, link: Link, 'list-item': ListItem, list: List, paragraph: Paragraph, pullquote: Pullquote, 'raw-image': FallbackImage, recommended: Recommended, 'recommended-list': RecommendedList, 'scrolly-block': ScrollyBlock, 'scrolly-copy': ScrollyCopy, 'scrolly-heading': ScrollyHeading, 'scrolly-image': ScrollyImage, 'scrolly-section': ScrollySection, strikethrough: Strikethrough, strong: Strong, 'table-body': TableBody, 'table-caption': TableCaption, 'table-cell': TableCell, 'table-footer': TableFooter, 'table-row': TableRow, table: Table, 'thematic-break': HorizontalRule, tweet: Tweet, video: Video, 'youtube-video': YoutubeVideo, timeline: Timeline, 'timeline-event': TimelineEvent, card: Card, 'info-box': InfoBox, 'info-pair': InfoPair, 'in-numbers': InNumbers, definition: Definition, } function isParentNode( node: ContentTreeWorkarounds.AnyNode | ContentTree.Node ): node is ContentTree.Parent { return 'children' in node } type RichTextChildProps = { node: ContentTreeWorkarounds.AnyNode components: RichTextComponentMapRecord references: StructuredContentFragment['references'] parentIndex: number } const RichTextChild: React.FC = ({ node, components, references, parentIndex, }) => { if (node.type === 'text') { return <>{node.value} } let Component = components[node.type] if (!Component) { console.warn( `couldn't find component for Content Tree node ${node.type}, using fallback component instead (by default will render the node's children)` ) Component = components.fallback } if (Component) { const children: ReactNode[] = isParentNode(node) ? node.children .map((child, index) => ( )) .filter(Boolean) : [] const reference: | StructuredContentFragment['references'][number] | Record = typeof node.data?.referenceIndex === 'number' ? references[node.data?.referenceIndex] ?? {} : {} return ( {children} ) } return null } const RichText: React.FC = ({ structuredContent, components, }) => { if (!structuredContent) { return null } const componentsWithOverrides = { ...componentMap, ...useContext(ComponentsContext), ...components, } const tree: ContentTree.Body = structuredContent.tree return ( <> {tree?.children?.map((node, index) => ( ))} ) } export default RichText