@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.
Files changed (89) hide show
  1. package/.toolkitrc.yml +12 -0
  2. package/CHANGELOG.md +62 -0
  3. package/README.md +27 -0
  4. package/jest.config.mjs +6 -0
  5. package/lib/components/Body/index.d.ts +8 -0
  6. package/lib/components/Body/index.js +22 -0
  7. package/lib/components/Body/index.js.map +1 -0
  8. package/lib/components/Byline/index.d.ts +7 -0
  9. package/lib/components/Byline/index.js +12 -0
  10. package/lib/components/Byline/index.js.map +1 -0
  11. package/lib/components/ImageSet/index.d.ts +3 -0
  12. package/lib/components/ImageSet/index.js +64 -0
  13. package/lib/components/ImageSet/index.js.map +1 -0
  14. package/lib/components/Layout/index.d.ts +17 -0
  15. package/lib/components/Layout/index.js +20 -0
  16. package/lib/components/Layout/index.js.map +1 -0
  17. package/lib/components/ListItem/index.d.ts +4 -0
  18. package/lib/components/ListItem/index.js +11 -0
  19. package/lib/components/ListItem/index.js.map +1 -0
  20. package/lib/components/Recommended/RecommendedTitle.d.ts +4 -0
  21. package/lib/components/Recommended/RecommendedTitle.js +11 -0
  22. package/lib/components/Recommended/RecommendedTitle.js.map +1 -0
  23. package/lib/components/Recommended/index.d.ts +3 -0
  24. package/lib/components/Recommended/index.js +16 -0
  25. package/lib/components/Recommended/index.js.map +1 -0
  26. package/lib/components/RichText/index.d.ts +16 -0
  27. package/lib/components/RichText/index.js +112 -0
  28. package/lib/components/RichText/index.js.map +1 -0
  29. package/lib/components/RichText/index.test.d.ts +1 -0
  30. package/lib/components/RichText/index.test.js +170 -0
  31. package/lib/components/RichText/index.test.js.map +1 -0
  32. package/lib/components/Topper/Columnist.d.ts +5 -0
  33. package/lib/components/Topper/Columnist.js +14 -0
  34. package/lib/components/Topper/Columnist.js.map +1 -0
  35. package/lib/components/Topper/Headline.d.ts +7 -0
  36. package/lib/components/Topper/Headline.js +20 -0
  37. package/lib/components/Topper/Headline.js.map +1 -0
  38. package/lib/components/Topper/Headshot.d.ts +4 -0
  39. package/lib/components/Topper/Headshot.js +12 -0
  40. package/lib/components/Topper/Headshot.js.map +1 -0
  41. package/lib/components/Topper/Intro.d.ts +7 -0
  42. package/lib/components/Topper/Intro.js +13 -0
  43. package/lib/components/Topper/Intro.js.map +1 -0
  44. package/lib/components/Topper/Picture.d.ts +8 -0
  45. package/lib/components/Topper/Picture.js +27 -0
  46. package/lib/components/Topper/Picture.js.map +1 -0
  47. package/lib/components/Topper/Tags.d.ts +9 -0
  48. package/lib/components/Topper/Tags.js +24 -0
  49. package/lib/components/Topper/Tags.js.map +1 -0
  50. package/lib/components/Topper/Wrapper.d.ts +8 -0
  51. package/lib/components/Topper/Wrapper.js +20 -0
  52. package/lib/components/Topper/Wrapper.js.map +1 -0
  53. package/lib/components/Topper/index.d.ts +7 -0
  54. package/lib/components/Topper/index.js +39 -0
  55. package/lib/components/Topper/index.js.map +1 -0
  56. package/lib/components/UnorderedList/index.d.ts +4 -0
  57. package/lib/components/UnorderedList/index.js +11 -0
  58. package/lib/components/UnorderedList/index.js.map +1 -0
  59. package/lib/index.d.ts +9 -0
  60. package/lib/index.js +27 -0
  61. package/lib/index.js.map +1 -0
  62. package/lib/index.test.d.ts +1 -0
  63. package/lib/index.test.js +4 -0
  64. package/lib/index.test.js.map +1 -0
  65. package/package.json +27 -0
  66. package/src/components/Body/index.tsx +35 -0
  67. package/src/components/Byline/index.tsx +12 -0
  68. package/src/components/ImageSet/index.tsx +123 -0
  69. package/src/components/Layout/index.tsx +44 -0
  70. package/src/components/ListItem/index.tsx +5 -0
  71. package/src/components/Recommended/RecommendedTitle.tsx +9 -0
  72. package/src/components/Recommended/index.tsx +23 -0
  73. package/src/components/RichText/README.md +34 -0
  74. package/src/components/RichText/index.test.tsx +166 -0
  75. package/src/components/RichText/index.tsx +136 -0
  76. package/src/components/Topper/Columnist.tsx +21 -0
  77. package/src/components/Topper/Headline.tsx +27 -0
  78. package/src/components/Topper/Headshot.tsx +9 -0
  79. package/src/components/Topper/Intro.tsx +16 -0
  80. package/src/components/Topper/Picture.tsx +70 -0
  81. package/src/components/Topper/Tags.tsx +61 -0
  82. package/src/components/Topper/Wrapper.tsx +26 -0
  83. package/src/components/Topper/index.tsx +69 -0
  84. package/src/components/UnorderedList/index.tsx +5 -0
  85. package/src/index.test.ts +1 -0
  86. package/src/index.ts +9 -0
  87. package/src/types/x-teaser.d.ts +1 -0
  88. package/tsconfig.json +14 -0
  89. 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,5 @@
1
+ import React, { ReactNode } from 'react'
2
+
3
+ export default function ListItem({ children }: { children: ReactNode }) {
4
+ return <li>{children}</li>
5
+ }
@@ -0,0 +1,9 @@
1
+ import React, { ReactNode } from 'react'
2
+
3
+ export default function RecommendedTitle({
4
+ children,
5
+ }: {
6
+ children: ReactNode
7
+ }) {
8
+ return <h2 className="n-content-recommended__title">{children}</h2>
9
+ }
@@ -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,9 @@
1
+ import React from 'react'
2
+
3
+ export default function Headshot({ url }: { url: string }) {
4
+ return (
5
+ <div className="o-topper__headshot">
6
+ <img className="o-topper__headshot-image" src={url} role="presentation" />
7
+ </div>
8
+ )
9
+ }
@@ -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
+ }