@financial-times/cp-content-pipeline-ui 0.1.2
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/.toolkitrc.yml +12 -0
- package/CHANGELOG.md +62 -0
- package/README.md +27 -0
- package/jest.config.mjs +6 -0
- package/lib/components/Body/index.d.ts +8 -0
- package/lib/components/Body/index.js +22 -0
- package/lib/components/Body/index.js.map +1 -0
- package/lib/components/Byline/index.d.ts +7 -0
- package/lib/components/Byline/index.js +12 -0
- package/lib/components/Byline/index.js.map +1 -0
- package/lib/components/ImageSet/index.d.ts +3 -0
- package/lib/components/ImageSet/index.js +64 -0
- package/lib/components/ImageSet/index.js.map +1 -0
- package/lib/components/Layout/index.d.ts +17 -0
- package/lib/components/Layout/index.js +20 -0
- package/lib/components/Layout/index.js.map +1 -0
- package/lib/components/ListItem/index.d.ts +4 -0
- package/lib/components/ListItem/index.js +11 -0
- package/lib/components/ListItem/index.js.map +1 -0
- package/lib/components/Recommended/RecommendedTitle.d.ts +4 -0
- package/lib/components/Recommended/RecommendedTitle.js +11 -0
- package/lib/components/Recommended/RecommendedTitle.js.map +1 -0
- package/lib/components/Recommended/index.d.ts +3 -0
- package/lib/components/Recommended/index.js +16 -0
- package/lib/components/Recommended/index.js.map +1 -0
- package/lib/components/RichText/index.d.ts +16 -0
- package/lib/components/RichText/index.js +112 -0
- package/lib/components/RichText/index.js.map +1 -0
- package/lib/components/RichText/index.test.d.ts +1 -0
- package/lib/components/RichText/index.test.js +170 -0
- package/lib/components/RichText/index.test.js.map +1 -0
- package/lib/components/Topper/Columnist.d.ts +5 -0
- package/lib/components/Topper/Columnist.js +14 -0
- package/lib/components/Topper/Columnist.js.map +1 -0
- package/lib/components/Topper/Headline.d.ts +7 -0
- package/lib/components/Topper/Headline.js +20 -0
- package/lib/components/Topper/Headline.js.map +1 -0
- package/lib/components/Topper/Headshot.d.ts +4 -0
- package/lib/components/Topper/Headshot.js +12 -0
- package/lib/components/Topper/Headshot.js.map +1 -0
- package/lib/components/Topper/Intro.d.ts +7 -0
- package/lib/components/Topper/Intro.js +13 -0
- package/lib/components/Topper/Intro.js.map +1 -0
- package/lib/components/Topper/Picture.d.ts +8 -0
- package/lib/components/Topper/Picture.js +27 -0
- package/lib/components/Topper/Picture.js.map +1 -0
- package/lib/components/Topper/Tags.d.ts +9 -0
- package/lib/components/Topper/Tags.js +24 -0
- package/lib/components/Topper/Tags.js.map +1 -0
- package/lib/components/Topper/Wrapper.d.ts +8 -0
- package/lib/components/Topper/Wrapper.js +20 -0
- package/lib/components/Topper/Wrapper.js.map +1 -0
- package/lib/components/Topper/index.d.ts +7 -0
- package/lib/components/Topper/index.js +39 -0
- package/lib/components/Topper/index.js.map +1 -0
- package/lib/components/UnorderedList/index.d.ts +4 -0
- package/lib/components/UnorderedList/index.js +11 -0
- package/lib/components/UnorderedList/index.js.map +1 -0
- package/lib/index.d.ts +9 -0
- package/lib/index.js +27 -0
- package/lib/index.js.map +1 -0
- package/lib/index.test.d.ts +1 -0
- package/lib/index.test.js +4 -0
- package/lib/index.test.js.map +1 -0
- package/package.json +27 -0
- package/src/components/Body/index.tsx +35 -0
- package/src/components/Byline/index.tsx +12 -0
- package/src/components/ImageSet/index.tsx +123 -0
- package/src/components/Layout/index.tsx +44 -0
- package/src/components/ListItem/index.tsx +5 -0
- package/src/components/Recommended/RecommendedTitle.tsx +9 -0
- package/src/components/Recommended/index.tsx +23 -0
- package/src/components/RichText/README.md +34 -0
- package/src/components/RichText/index.test.tsx +166 -0
- package/src/components/RichText/index.tsx +136 -0
- package/src/components/Topper/Columnist.tsx +21 -0
- package/src/components/Topper/Headline.tsx +27 -0
- package/src/components/Topper/Headshot.tsx +9 -0
- package/src/components/Topper/Intro.tsx +16 -0
- package/src/components/Topper/Picture.tsx +70 -0
- package/src/components/Topper/Tags.tsx +61 -0
- package/src/components/Topper/Wrapper.tsx +26 -0
- package/src/components/Topper/index.tsx +69 -0
- package/src/components/UnorderedList/index.tsx +5 -0
- package/src/index.test.ts +1 -0
- package/src/index.ts +9 -0
- package/src/types/x-teaser.d.ts +1 -0
- package/tsconfig.json +14 -0
- package/tsconfig.tsbuildinfo +1 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
ImageFragment,
|
|
5
|
+
ImageSetFragment,
|
|
6
|
+
PictureFragment,
|
|
7
|
+
ImageType,
|
|
8
|
+
} from '@financial-times/cp-content-pipeline-client'
|
|
9
|
+
|
|
10
|
+
type ImageProps = {
|
|
11
|
+
image: ImageFragment
|
|
12
|
+
alt: string
|
|
13
|
+
type: ImageType
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const srcSet = (image: ImageFragment) =>
|
|
17
|
+
image.sources.map((src) => `${src.url} ${src.dpr}x`).join(',')
|
|
18
|
+
|
|
19
|
+
const figureClassNameMap: Record<PictureFragment['__typename'], string> = {
|
|
20
|
+
PictureFullBleed:
|
|
21
|
+
'n-content-picture n-content-picture--wide n-content-layout__container',
|
|
22
|
+
PictureInline: 'n-content-image n-content-image--inline',
|
|
23
|
+
PictureStandard: 'n-content-image n-content-image--full',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function Source({ image }: { image: ImageFragment }) {
|
|
27
|
+
const media = [
|
|
28
|
+
image.minDisplayWidth && `(min-width: ${image.minDisplayWidth})`,
|
|
29
|
+
image.maxDisplayWidth && `(max-width: ${image.maxDisplayWidth})`,
|
|
30
|
+
]
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.join(' and ')
|
|
33
|
+
|
|
34
|
+
// only need to have sources for different screen breakpoints
|
|
35
|
+
if (!media.length) return null
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<source
|
|
39
|
+
media={media}
|
|
40
|
+
srcSet={srcSet(image)}
|
|
41
|
+
width={image.originalWidth}
|
|
42
|
+
height={image.originalHeight}
|
|
43
|
+
/>
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function Image({ image, alt, type }: ImageProps) {
|
|
48
|
+
const props = {
|
|
49
|
+
src: image.sources[0].url,
|
|
50
|
+
alt,
|
|
51
|
+
srcSet: image.sources.length > 1 ? srcSet(image) : undefined,
|
|
52
|
+
'data-image-type': type.toLowerCase(),
|
|
53
|
+
width: image.originalWidth,
|
|
54
|
+
height: image.originalHeight,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return <img {...props} />
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// type is too disparate to make fragments for, so define it here
|
|
61
|
+
type PictureImages<T> = {
|
|
62
|
+
__typename?: 'PictureImages'
|
|
63
|
+
[key: string]: T | 'PictureImages' | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isValidImage = <T,>(image: T | 'PictureImages' | undefined): image is T =>
|
|
67
|
+
Boolean(image) && image !== 'PictureImages'
|
|
68
|
+
|
|
69
|
+
const validImages = <T,>(images: PictureImages<T>): T[] =>
|
|
70
|
+
Object.values(images).filter(isValidImage)
|
|
71
|
+
|
|
72
|
+
function Figure({ picture }: ImageSetFragment) {
|
|
73
|
+
if (!picture.images) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`ImageSet of type ${picture.__typename} has no images. Check that they are present in the query`
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
!picture.images.standard ||
|
|
80
|
+
'minDisplayWidth' in picture.images.standard ||
|
|
81
|
+
'maxDisplayWidth' in picture.images.standard
|
|
82
|
+
) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`ImageSet must include a "standard" image, without any breakpoint restrictions.`
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const pictureImages: ImageFragment[] = validImages(picture.images)
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<figure
|
|
92
|
+
className={figureClassNameMap[picture.__typename] || 'n-content-image'}
|
|
93
|
+
>
|
|
94
|
+
<picture>
|
|
95
|
+
{pictureImages.map((image: ImageFragment, index: number) => (
|
|
96
|
+
<Source key={index} image={image} />
|
|
97
|
+
))}
|
|
98
|
+
<Image
|
|
99
|
+
image={picture.images.standard}
|
|
100
|
+
alt={picture.alt}
|
|
101
|
+
type={picture.imageType}
|
|
102
|
+
/>
|
|
103
|
+
</picture>
|
|
104
|
+
{picture.caption && (
|
|
105
|
+
<figcaption className="n-content-picture__caption">
|
|
106
|
+
{picture.caption}
|
|
107
|
+
</figcaption>
|
|
108
|
+
)}
|
|
109
|
+
</figure>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export default function ImageSet(fragment: ImageSetFragment) {
|
|
114
|
+
if (fragment.picture.__typename === 'PictureFullBleed') {
|
|
115
|
+
return (
|
|
116
|
+
<div className="n-content-layout" data-layout-width="full-grid">
|
|
117
|
+
<Figure {...fragment} />
|
|
118
|
+
</div>
|
|
119
|
+
)
|
|
120
|
+
} else {
|
|
121
|
+
return <Figure {...fragment} />
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import React, { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
type LayoutProps = {
|
|
4
|
+
children: ReactNode
|
|
5
|
+
dataLayoutName: string
|
|
6
|
+
dataLayoutWidth: 'full-grid' | 'fullWidth'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type LayoutContainerProps = {
|
|
10
|
+
children: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type LayoutSlotProps = {
|
|
14
|
+
children: ReactNode
|
|
15
|
+
dataLayoutWidth: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function Layout({
|
|
19
|
+
children,
|
|
20
|
+
dataLayoutName,
|
|
21
|
+
dataLayoutWidth,
|
|
22
|
+
}: LayoutProps) {
|
|
23
|
+
return (
|
|
24
|
+
<div
|
|
25
|
+
className="n-content-layout"
|
|
26
|
+
data-layout-name={dataLayoutName}
|
|
27
|
+
data-layout-width={dataLayoutWidth}
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function LayoutContainer({ children }: LayoutContainerProps) {
|
|
35
|
+
return <div className="n-content-layout__container">{children}</div>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function LayoutSlot({ children, dataLayoutWidth }: LayoutSlotProps) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="n-content-layout__slot" data-layout-width={dataLayoutWidth}>
|
|
41
|
+
{children}
|
|
42
|
+
</div>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
//HACK: worked around missing Teaser type by declaring a module x-teaser.d.ts
|
|
4
|
+
import { presets, Teaser } from '@financial-times/x-teaser/dist/Teaser.cjs.js'
|
|
5
|
+
import RecommendedTitle from './RecommendedTitle'
|
|
6
|
+
|
|
7
|
+
import type { RecommendedFragment } from '@financial-times/cp-content-pipeline-client'
|
|
8
|
+
|
|
9
|
+
export default function RecommendedComponent({
|
|
10
|
+
title,
|
|
11
|
+
teaser,
|
|
12
|
+
}: RecommendedFragment) {
|
|
13
|
+
return (
|
|
14
|
+
<aside className="n-content-recommended--single-story">
|
|
15
|
+
<RecommendedTitle>{title}</RecommendedTitle>
|
|
16
|
+
<Teaser
|
|
17
|
+
modifiers={['stacked']}
|
|
18
|
+
{...presets.SmallHeavy}
|
|
19
|
+
{...teaser}
|
|
20
|
+
/>
|
|
21
|
+
</aside>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# RichText
|
|
2
|
+
|
|
3
|
+
A JSX component that takes structured content as output from the GraphQL API, iterates through the tree, and renders JSX components for each tag.
|
|
4
|
+
|
|
5
|
+
## Using
|
|
6
|
+
|
|
7
|
+
```jsx
|
|
8
|
+
const structuredContent = {
|
|
9
|
+
tree: hastTree,
|
|
10
|
+
references: [{
|
|
11
|
+
__typename: 'Recommended',
|
|
12
|
+
teaser: {}
|
|
13
|
+
}] // array of additional props to pass to components
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const componentOverrides = {
|
|
17
|
+
Link: () => null,
|
|
18
|
+
Image: MyImageComponent
|
|
19
|
+
}
|
|
20
|
+
<RichText structuredContent={structuredContent} components={componentOverrides} />
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## What is a tree?
|
|
25
|
+
|
|
26
|
+
The `tree` should be a body of content represented as a [hast](https://github.com/syntax-tree/hast) abstract syntax tree. The `tagName` for the nodes should correspond to a component that can be rendered.
|
|
27
|
+
|
|
28
|
+
## What are references?
|
|
29
|
+
|
|
30
|
+
A reference is essentially an object providing additional props, that can be passed to the components rendering children in the tree.
|
|
31
|
+
|
|
32
|
+
A node in the tree can have a property `dataReferenceId`, which corresponds to an index in the references array holding the additional props for that node. The RichText component will automatically pass those additional props to the corresponding JSX component for that node.
|
|
33
|
+
|
|
34
|
+
A use case for this is for Recommended nodes, which require additional teaser data to render that isn't available in the original content tree.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import React, { createElement } from 'react'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import RichText from '.'
|
|
4
|
+
|
|
5
|
+
describe('<RichText />', () => {
|
|
6
|
+
it('renders a HAST tree using built-in components', () => {
|
|
7
|
+
const tree = {
|
|
8
|
+
type: 'root' as 'root',
|
|
9
|
+
children: [
|
|
10
|
+
{
|
|
11
|
+
type: 'element' as 'element',
|
|
12
|
+
tagName: 'Paragraph',
|
|
13
|
+
children: [{ type: 'text' as 'text', value: 'This is some text.' }],
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
}
|
|
17
|
+
const structuredContent = { tree }
|
|
18
|
+
|
|
19
|
+
const { asFragment } = render(
|
|
20
|
+
<RichText structuredContent={structuredContent} />
|
|
21
|
+
)
|
|
22
|
+
expect(asFragment()).toMatchInlineSnapshot(`
|
|
23
|
+
<DocumentFragment>
|
|
24
|
+
<p>
|
|
25
|
+
This is some text.
|
|
26
|
+
</p>
|
|
27
|
+
</DocumentFragment>
|
|
28
|
+
`)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('adds a key property to all child components', () => {
|
|
32
|
+
jest.spyOn(React, 'createElement')
|
|
33
|
+
const tree = {
|
|
34
|
+
type: 'root' as 'root',
|
|
35
|
+
children: [
|
|
36
|
+
{
|
|
37
|
+
type: 'element' as 'element',
|
|
38
|
+
tagName: 'Link',
|
|
39
|
+
properties: {
|
|
40
|
+
test: 'blah',
|
|
41
|
+
},
|
|
42
|
+
children: [
|
|
43
|
+
{
|
|
44
|
+
type: 'text' as 'text',
|
|
45
|
+
value: 'first link',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'element' as 'element',
|
|
51
|
+
tagName: 'Link',
|
|
52
|
+
children: [
|
|
53
|
+
{
|
|
54
|
+
type: 'text' as 'text',
|
|
55
|
+
value: 'second link',
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
const structuredContent = {
|
|
62
|
+
tree,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
render(<RichText structuredContent={structuredContent} />)
|
|
66
|
+
expect(createElement).toHaveBeenCalledWith(
|
|
67
|
+
expect.any(Function),
|
|
68
|
+
expect.objectContaining({ key: 0 }),
|
|
69
|
+
['first link']
|
|
70
|
+
)
|
|
71
|
+
expect(createElement).toHaveBeenCalledWith(
|
|
72
|
+
expect.any(Function),
|
|
73
|
+
expect.objectContaining({ key: 1 }),
|
|
74
|
+
['second link']
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it("ignores components that it doesn't have a mapping for", () => {
|
|
79
|
+
const tree = {
|
|
80
|
+
type: 'root' as 'root',
|
|
81
|
+
children: [
|
|
82
|
+
{
|
|
83
|
+
type: 'element' as 'element',
|
|
84
|
+
tagName: 'Link',
|
|
85
|
+
properties: {
|
|
86
|
+
test: 'blah',
|
|
87
|
+
},
|
|
88
|
+
children: [
|
|
89
|
+
{
|
|
90
|
+
type: 'text' as 'text',
|
|
91
|
+
value: 'i should render',
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
type: 'element' as 'element',
|
|
97
|
+
tagName: 'Unknown',
|
|
98
|
+
children: [
|
|
99
|
+
{
|
|
100
|
+
type: 'text' as 'text',
|
|
101
|
+
value: 'i should not render',
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
}
|
|
107
|
+
const structuredContent = {
|
|
108
|
+
tree,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { queryByText } = render(
|
|
112
|
+
<RichText structuredContent={structuredContent} />
|
|
113
|
+
)
|
|
114
|
+
expect(queryByText('i should render')).toBeTruthy()
|
|
115
|
+
expect(queryByText('i should not render')).toBeFalsy()
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('adds references as props', () => {
|
|
119
|
+
const tree = {
|
|
120
|
+
type: 'root' as 'root',
|
|
121
|
+
children: [
|
|
122
|
+
{
|
|
123
|
+
type: 'element' as 'element',
|
|
124
|
+
tagName: 'Link',
|
|
125
|
+
properties: {
|
|
126
|
+
dataReferenceId: 0,
|
|
127
|
+
},
|
|
128
|
+
children: [
|
|
129
|
+
{
|
|
130
|
+
type: 'text' as 'text',
|
|
131
|
+
value: 'withreference',
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
type: 'element' as 'element',
|
|
137
|
+
tagName: 'Link',
|
|
138
|
+
children: [
|
|
139
|
+
{
|
|
140
|
+
type: 'text' as 'text',
|
|
141
|
+
value: 'withoutreference',
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
}
|
|
147
|
+
const structuredContent = {
|
|
148
|
+
tree,
|
|
149
|
+
references: [
|
|
150
|
+
{
|
|
151
|
+
__typename: 'Link' as const,
|
|
152
|
+
href: 'https://www.ft.com/',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { getByText } = render(
|
|
158
|
+
<RichText structuredContent={structuredContent} />
|
|
159
|
+
)
|
|
160
|
+
expect(getByText('withreference')).toHaveProperty(
|
|
161
|
+
'href',
|
|
162
|
+
'https://www.ft.com/'
|
|
163
|
+
)
|
|
164
|
+
expect(getByText('withoutreference')).toHaveProperty('href', '')
|
|
165
|
+
})
|
|
166
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import React, { createElement, ReactNode } from 'react'
|
|
2
|
+
import Recommended from '../Recommended'
|
|
3
|
+
import ImageSet from '../ImageSet'
|
|
4
|
+
import RecommendedTitle from '../Recommended/RecommendedTitle'
|
|
5
|
+
import UnorderedList from '../UnorderedList'
|
|
6
|
+
import ListItem from '../ListItem'
|
|
7
|
+
import { Layout, LayoutContainer, LayoutSlot } from '../Layout'
|
|
8
|
+
|
|
9
|
+
import * as hast from 'hast'
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
ArticleReferencesFragment,
|
|
13
|
+
Reference,
|
|
14
|
+
StructuredContent,
|
|
15
|
+
StructuredContentFragment,
|
|
16
|
+
} from '@financial-times/cp-content-pipeline-client'
|
|
17
|
+
|
|
18
|
+
export type ComponentMapRecord = Record<string, React.ElementType>
|
|
19
|
+
|
|
20
|
+
export type RenderHastTreeArguments = {
|
|
21
|
+
root: hast.Root
|
|
22
|
+
components: ComponentMapRecord
|
|
23
|
+
references?: Reference[]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type WithOptionalProperty<T, K extends keyof T> = Pick<Partial<T>, K> &
|
|
27
|
+
Omit<T, K>
|
|
28
|
+
|
|
29
|
+
export type RichTextProps = {
|
|
30
|
+
// the component using RichText might not have queried for references, so make it optional
|
|
31
|
+
structuredContent: WithOptionalProperty<
|
|
32
|
+
StructuredContentFragment,
|
|
33
|
+
'references'
|
|
34
|
+
>
|
|
35
|
+
components?: ComponentMapRecord
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* This will be the default set of components we provide, that most
|
|
39
|
+
* clients can use to render a workable article page
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const componentMap: ComponentMapRecord = {
|
|
43
|
+
Paragraph: 'p',
|
|
44
|
+
Recommended,
|
|
45
|
+
UnorderedList,
|
|
46
|
+
ListItem,
|
|
47
|
+
FTContent: (props) => <a href={props.url}>{props.children}</a>,
|
|
48
|
+
RecommendedTitle,
|
|
49
|
+
Link: (props) => <a href={props.href}>{props.children}</a>,
|
|
50
|
+
ImageSet,
|
|
51
|
+
Layout,
|
|
52
|
+
LayoutContainer,
|
|
53
|
+
LayoutSlot,
|
|
54
|
+
LayoutImage: ImageSet,
|
|
55
|
+
Experimental: (props) => props.children,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isHastElement(element: hast.Content): element is hast.Element {
|
|
59
|
+
return (element as hast.Element).tagName !== undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
class HastToJSX {
|
|
63
|
+
components: ComponentMapRecord
|
|
64
|
+
references?: StructuredContentFragment['references']
|
|
65
|
+
index = 0
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
components: ComponentMapRecord,
|
|
69
|
+
references?: StructuredContentFragment['references']
|
|
70
|
+
) {
|
|
71
|
+
this.components = components
|
|
72
|
+
this.references = references
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
#getPropsForNode(node: hast.Element) {
|
|
76
|
+
const reference =
|
|
77
|
+
typeof node.properties?.dataReferenceId === 'number'
|
|
78
|
+
? this.references?.[node.properties?.dataReferenceId]
|
|
79
|
+
: {}
|
|
80
|
+
return {
|
|
81
|
+
...node.properties,
|
|
82
|
+
...reference,
|
|
83
|
+
key: this.index++,
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#renderChild(node: hast.Content) {
|
|
88
|
+
if (node.type === 'text') {
|
|
89
|
+
return node.value
|
|
90
|
+
} else if (!isHastElement(node)) {
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const component = this.components[node.tagName]
|
|
95
|
+
|
|
96
|
+
if (component) {
|
|
97
|
+
const childElements: ReactNode[] = node.children
|
|
98
|
+
.map((child: hast.Content) => this.#renderChild(child))
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
|
|
101
|
+
return createElement(
|
|
102
|
+
component,
|
|
103
|
+
this.#getPropsForNode(node),
|
|
104
|
+
childElements
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
render(tree: hast.Root) {
|
|
110
|
+
const children = tree.children.map((node: hast.Content) =>
|
|
111
|
+
this.#renderChild(node)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return <>{children}</>
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export default function RichText({
|
|
119
|
+
structuredContent,
|
|
120
|
+
components,
|
|
121
|
+
}: RichTextProps) {
|
|
122
|
+
if (!structuredContent) {
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const componentsWithOverrides = {
|
|
127
|
+
...componentMap,
|
|
128
|
+
...components,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const renderer = new HastToJSX(
|
|
132
|
+
componentsWithOverrides,
|
|
133
|
+
structuredContent.references
|
|
134
|
+
)
|
|
135
|
+
return renderer.render(structuredContent.tree)
|
|
136
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { ConceptFragment } from '@financial-times/cp-content-pipeline-client'
|
|
3
|
+
|
|
4
|
+
//TODO: add myFT button stuff here
|
|
5
|
+
export default function Columnist({
|
|
6
|
+
authorConcept,
|
|
7
|
+
}: {
|
|
8
|
+
authorConcept: ConceptFragment
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<div className="o-topper__columnist">
|
|
12
|
+
<a
|
|
13
|
+
className="o-topper__columnist-name n-content-tag--with-follow"
|
|
14
|
+
href={authorConcept.relativeUrl ?? undefined}
|
|
15
|
+
data-trackable="columnist"
|
|
16
|
+
>
|
|
17
|
+
{authorConcept.prefLabel}
|
|
18
|
+
</a>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import classnames from 'classnames'
|
|
3
|
+
|
|
4
|
+
type HeadlineProps = {
|
|
5
|
+
headline: string
|
|
6
|
+
isLargeHeadline?: boolean
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export default function Headline({
|
|
10
|
+
headline,
|
|
11
|
+
isLargeHeadline = false,
|
|
12
|
+
}: HeadlineProps) {
|
|
13
|
+
if (!headline) {
|
|
14
|
+
return <></>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const headlineClassnames = classnames({
|
|
18
|
+
'o-topper__headline': true,
|
|
19
|
+
[`o-topper__headline--large`]: isLargeHeadline,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<h1 className={headlineClassnames}>
|
|
24
|
+
<span className="article-classifier__gap">{headline}</span>
|
|
25
|
+
</h1>
|
|
26
|
+
)
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import RichText from '../RichText'
|
|
3
|
+
import type { StructuredTreeFragment } from '@financial-times/cp-content-pipeline-client'
|
|
4
|
+
|
|
5
|
+
export type IntroProps = {
|
|
6
|
+
source: 'standfirst' | 'summary'
|
|
7
|
+
structuredContent: StructuredTreeFragment
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function Intro({ structuredContent, source }: IntroProps) {
|
|
11
|
+
return (
|
|
12
|
+
<div className={`o-topper__${source}`}>
|
|
13
|
+
<RichText structuredContent={structuredContent} />
|
|
14
|
+
</div>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
TopperImagesFragment,
|
|
5
|
+
ImageFragment,
|
|
6
|
+
} from '@financial-times/cp-content-pipeline-client'
|
|
7
|
+
|
|
8
|
+
type PictureProps = {
|
|
9
|
+
images?: TopperImagesFragment
|
|
10
|
+
alt?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function generateMediaQuery(image: ImageFragment): string {
|
|
14
|
+
const queries = []
|
|
15
|
+
image.minDisplayWidth && queries.push(`(min-width: ${image.minDisplayWidth})`)
|
|
16
|
+
image.maxDisplayWidth && queries.push(`(max-width: ${image.maxDisplayWidth})`)
|
|
17
|
+
|
|
18
|
+
return queries.join(' and ')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const srcSet = (image: ImageFragment) =>
|
|
22
|
+
image.sources.map((src) => `${src.url} ${src.dpr}x`).join(',')
|
|
23
|
+
|
|
24
|
+
export default function Picture({ images, alt = '' }: PictureProps) {
|
|
25
|
+
if (!images?.fallback) {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<figure className="o-topper__visual">
|
|
31
|
+
<picture className="o-topper__picture" key="responsive-images">
|
|
32
|
+
{images?.square && (
|
|
33
|
+
<source
|
|
34
|
+
media={generateMediaQuery(images.square)}
|
|
35
|
+
srcSet={srcSet(images.square)}
|
|
36
|
+
data-testid="responsive-source"
|
|
37
|
+
key={'square'}
|
|
38
|
+
/>
|
|
39
|
+
)}
|
|
40
|
+
{images?.standard && (
|
|
41
|
+
<source
|
|
42
|
+
media={generateMediaQuery(images.standard)}
|
|
43
|
+
srcSet={srcSet(images.standard)}
|
|
44
|
+
data-testid="responsive-source"
|
|
45
|
+
key={'standard'}
|
|
46
|
+
/>
|
|
47
|
+
)}
|
|
48
|
+
{images?.wide && (
|
|
49
|
+
<source
|
|
50
|
+
media={generateMediaQuery(images.wide)}
|
|
51
|
+
srcSet={srcSet(images.wide)}
|
|
52
|
+
data-testid="responsive-source"
|
|
53
|
+
key={'standard'}
|
|
54
|
+
/>
|
|
55
|
+
)}
|
|
56
|
+
<img
|
|
57
|
+
alt={alt}
|
|
58
|
+
className="o-topper__image"
|
|
59
|
+
src={`${images.fallback.sources[0].url}`}
|
|
60
|
+
key="fallback-image"
|
|
61
|
+
/>
|
|
62
|
+
</picture>
|
|
63
|
+
{images.fallback.copyright && (
|
|
64
|
+
<figcaption className="o-topper__image-credit">
|
|
65
|
+
{images.fallback.copyright}
|
|
66
|
+
</figcaption>
|
|
67
|
+
)}
|
|
68
|
+
</figure>
|
|
69
|
+
)
|
|
70
|
+
}
|