@financial-times/cp-content-pipeline-schema 0.2.1
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 +72 -0
- package/jest.config.js +3 -0
- package/lib/concept.d.ts +7 -0
- package/lib/concept.js +39 -0
- package/lib/concept.js.map +1 -0
- package/lib/constants/contentTypes.d.ts +2 -0
- package/lib/constants/contentTypes.js +3 -0
- package/lib/constants/contentTypes.js.map +1 -0
- package/lib/content.d.ts +55 -0
- package/lib/content.js +133 -0
- package/lib/content.js.map +1 -0
- package/lib/content.test.d.ts +1 -0
- package/lib/content.test.js +149 -0
- package/lib/content.test.js.map +1 -0
- package/lib/datasources/capi.d.ts +10 -0
- package/lib/datasources/capi.js +28 -0
- package/lib/datasources/capi.js.map +1 -0
- package/lib/datasources/index.d.ts +9 -0
- package/lib/datasources/index.js +9 -0
- package/lib/datasources/index.js.map +1 -0
- package/lib/datasources/origami-image.d.ts +8 -0
- package/lib/datasources/origami-image.js +11 -0
- package/lib/datasources/origami-image.js.map +1 -0
- package/lib/datasources/url-management.d.ts +11 -0
- package/lib/datasources/url-management.js +40 -0
- package/lib/datasources/url-management.js.map +1 -0
- package/lib/datasources/url-management.test.d.ts +1 -0
- package/lib/datasources/url-management.test.js +69 -0
- package/lib/datasources/url-management.test.js.map +1 -0
- package/lib/helpers/byline.d.ts +1 -0
- package/lib/helpers/byline.js +5 -0
- package/lib/helpers/byline.js.map +1 -0
- package/lib/helpers/imageService.d.ts +8 -0
- package/lib/helpers/imageService.js +13 -0
- package/lib/helpers/imageService.js.map +1 -0
- package/lib/helpers/metadata.d.ts +12 -0
- package/lib/helpers/metadata.js +60 -0
- package/lib/helpers/metadata.js.map +1 -0
- package/lib/helpers/syntaxTree.d.ts +23 -0
- package/lib/helpers/syntaxTree.js +23 -0
- package/lib/helpers/syntaxTree.js.map +1 -0
- package/lib/image.d.ts +25 -0
- package/lib/image.js +123 -0
- package/lib/image.js.map +1 -0
- package/lib/image.test.d.ts +1 -0
- package/lib/image.test.js +235 -0
- package/lib/image.test.js.map +1 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +69 -0
- package/lib/index.js.map +1 -0
- package/lib/picture.d.ts +22 -0
- package/lib/picture.js +80 -0
- package/lib/picture.js.map +1 -0
- package/lib/richText.d.ts +14 -0
- package/lib/richText.js +48 -0
- package/lib/richText.js.map +1 -0
- package/lib/tags.d.ts +13 -0
- package/lib/tags.js +178 -0
- package/lib/tags.js.map +1 -0
- package/lib/topper.d.ts +7 -0
- package/lib/topper.js +196 -0
- package/lib/topper.js.map +1 -0
- package/lib/unified-plugins/extract-references.d.ts +7 -0
- package/lib/unified-plugins/extract-references.js +36 -0
- package/lib/unified-plugins/extract-references.js.map +1 -0
- package/lib/unified-plugins/map-to-abstract-types.d.ts +4 -0
- package/lib/unified-plugins/map-to-abstract-types.js +17 -0
- package/lib/unified-plugins/map-to-abstract-types.js.map +1 -0
- package/package.json +43 -0
- package/src/__snapshots__/content.test.ts.snap +118 -0
- package/src/concept.ts +58 -0
- package/src/constants/contentTypes.ts +4 -0
- package/src/content.test.ts +163 -0
- package/src/content.ts +146 -0
- package/src/datasources/capi.ts +28 -0
- package/src/datasources/index.ts +11 -0
- package/src/datasources/origami-image.ts +10 -0
- package/src/datasources/url-management.test.ts +92 -0
- package/src/datasources/url-management.ts +65 -0
- package/src/helpers/byline.ts +4 -0
- package/src/helpers/imageService.ts +31 -0
- package/src/helpers/metadata.ts +88 -0
- package/src/helpers/syntaxTree.ts +26 -0
- package/src/image.test.ts +339 -0
- package/src/image.ts +154 -0
- package/src/index.ts +87 -0
- package/src/picture.ts +98 -0
- package/src/richText.ts +62 -0
- package/src/tags.ts +237 -0
- package/src/topper.ts +228 -0
- package/src/types/internal-content.d.ts +78 -0
- package/src/types/n-concept-ids.d.ts +16 -0
- package/src/types/n-display-metadata.d.ts +1 -0
- package/src/types/n-url-management-api-read-client.d.ts +11 -0
- package/src/types/next-metrics.d.ts +1 -0
- package/src/unified-plugins/extract-references.ts +50 -0
- package/src/unified-plugins/map-to-abstract-types.ts +21 -0
- package/tsconfig.json +10 -0
- package/tsconfig.tsbuildinfo +1 -0
package/src/picture.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { gql } from 'graphql-tag'
|
|
2
|
+
import { ImageSet } from './types/internal-content.js'
|
|
3
|
+
import { getImageDimensions } from './image.js'
|
|
4
|
+
|
|
5
|
+
import type { DataSources } from './datasources/index.js'
|
|
6
|
+
|
|
7
|
+
export const ImageType = {
|
|
8
|
+
Image: 'http://www.ft.com/ontology/content/Image',
|
|
9
|
+
Graphic: 'http://www.ft.com/ontology/content/Graphic',
|
|
10
|
+
} as const
|
|
11
|
+
|
|
12
|
+
const pictureFields = `
|
|
13
|
+
images: PictureImages
|
|
14
|
+
alt: String!
|
|
15
|
+
caption: String
|
|
16
|
+
imageType: ImageType!
|
|
17
|
+
`
|
|
18
|
+
|
|
19
|
+
export const typeDef = gql`
|
|
20
|
+
enum ImageType {
|
|
21
|
+
Image
|
|
22
|
+
Graphic
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type PictureImages {
|
|
26
|
+
standard: Image!
|
|
27
|
+
small: Image
|
|
28
|
+
large: Image
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Picture {
|
|
32
|
+
${pictureFields}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type PictureStandard implements Picture {
|
|
36
|
+
${pictureFields}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type PictureInline implements Picture {
|
|
40
|
+
${pictureFields}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
type PictureFullBleed implements Picture {
|
|
44
|
+
${pictureFields}
|
|
45
|
+
}
|
|
46
|
+
`
|
|
47
|
+
|
|
48
|
+
export const resolvers = {
|
|
49
|
+
Picture: {
|
|
50
|
+
async __resolveType(
|
|
51
|
+
parent: ImageSet,
|
|
52
|
+
context: { dataSources: DataSources }
|
|
53
|
+
) {
|
|
54
|
+
//TODO: actually work out the types
|
|
55
|
+
if (parent.members.length === 3) return 'PictureFullBleed'
|
|
56
|
+
const standard = parent.members[0]
|
|
57
|
+
const { width, height } = await getImageDimensions(
|
|
58
|
+
standard,
|
|
59
|
+
context.dataSources
|
|
60
|
+
)
|
|
61
|
+
if (width < 350 || (width < height && width < 580)) {
|
|
62
|
+
return 'PictureInline'
|
|
63
|
+
}
|
|
64
|
+
return 'PictureStandard'
|
|
65
|
+
},
|
|
66
|
+
images(parent: ImageSet) {
|
|
67
|
+
const standard = parent.members[0]
|
|
68
|
+
const large =
|
|
69
|
+
parent.members.find((member) => member.minDisplayWidth === '980px') ||
|
|
70
|
+
standard
|
|
71
|
+
const small =
|
|
72
|
+
parent.members.find((member) => member.maxDisplayWidth === '490px') ||
|
|
73
|
+
standard
|
|
74
|
+
|
|
75
|
+
return { standard, small, large }
|
|
76
|
+
},
|
|
77
|
+
caption(parent: ImageSet) {
|
|
78
|
+
const standard = parent.members[0]
|
|
79
|
+
const elements = [standard.title, standard.copyright?.notice].filter(
|
|
80
|
+
Boolean
|
|
81
|
+
)
|
|
82
|
+
return elements.length ? elements.join(' ') : null
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
alt(parent: ImageSet) {
|
|
86
|
+
const standard = parent.members[0]
|
|
87
|
+
const fallback =
|
|
88
|
+
standard.type === ImageType.Graphic
|
|
89
|
+
? 'A graphic with no description'
|
|
90
|
+
: ''
|
|
91
|
+
return (standard.description || fallback).replace(/"/g, '"')
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
imageType(parent: ImageSet) {
|
|
95
|
+
return parent.members[0].type
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
package/src/richText.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { unified, Plugin, PluggableList } from 'unified'
|
|
2
|
+
import { gql } from 'graphql-tag'
|
|
3
|
+
import rehypeParse from 'rehype-parse'
|
|
4
|
+
import extractReferences from './unified-plugins/extract-references.js'
|
|
5
|
+
import mapToAbstractTypes from './unified-plugins/map-to-abstract-types.js'
|
|
6
|
+
|
|
7
|
+
import type { InternalContent } from './types/internal-content.js'
|
|
8
|
+
|
|
9
|
+
type RichTextResolverData = {
|
|
10
|
+
source: string
|
|
11
|
+
value: string
|
|
12
|
+
contentApiData?: InternalContent
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const typeDef = gql`
|
|
16
|
+
type StructuredContent {
|
|
17
|
+
tree: JSON!
|
|
18
|
+
references: [Reference!]!
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
enum Source {
|
|
22
|
+
standfirst
|
|
23
|
+
summary
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
type RichText {
|
|
27
|
+
source: Source!
|
|
28
|
+
raw: String!
|
|
29
|
+
structured: StructuredContent!
|
|
30
|
+
}
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
export const resolvers = {
|
|
34
|
+
RichText: {
|
|
35
|
+
raw(parent: RichTextResolverData) {
|
|
36
|
+
return parent.value
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
async structured(parent: RichTextResolverData) {
|
|
40
|
+
const formatOutput: Plugin<[], Element, Element> =
|
|
41
|
+
function formatOutput() {
|
|
42
|
+
this.Compiler = (tree) => {
|
|
43
|
+
const references = this.data('references')
|
|
44
|
+
return { tree, references }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pluggableList: PluggableList = [
|
|
49
|
+
mapToAbstractTypes,
|
|
50
|
+
extractReferences({ contentApiData: parent.contentApiData }),
|
|
51
|
+
formatOutput,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const body = await unified()
|
|
55
|
+
.use(rehypeParse, { fragment: true })
|
|
56
|
+
.use(pluggableList)
|
|
57
|
+
.process(parent.value)
|
|
58
|
+
|
|
59
|
+
return body.result
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
}
|
package/src/tags.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { select, matches } from 'hast-util-select'
|
|
2
|
+
import { toText } from 'hast-util-to-text'
|
|
3
|
+
import { gql } from 'graphql-tag'
|
|
4
|
+
import { uuidFromUrl } from './helpers/metadata.js'
|
|
5
|
+
import type * as hast from 'hast'
|
|
6
|
+
import type { IObjectTypeResolver } from '@graphql-tools/utils'
|
|
7
|
+
import type { DocumentNode } from 'graphql'
|
|
8
|
+
import type {
|
|
9
|
+
InternalContent,
|
|
10
|
+
ImageSet as CAPIImageSet,
|
|
11
|
+
} from './types/internal-content.js'
|
|
12
|
+
|
|
13
|
+
export type Tag = object
|
|
14
|
+
|
|
15
|
+
interface TagConstructor {
|
|
16
|
+
new (node: hast.Element, contentApiData?: InternalContent): Tag
|
|
17
|
+
match(node: hast.Element): boolean
|
|
18
|
+
typeDef?: DocumentNode
|
|
19
|
+
resolve?: IObjectTypeResolver<any, any, any>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function createTagWithoutReference(
|
|
23
|
+
name: string,
|
|
24
|
+
selector: string
|
|
25
|
+
): TagConstructor {
|
|
26
|
+
const tag = class implements Tag {
|
|
27
|
+
static match(node: hast.Element) {
|
|
28
|
+
return matches(selector, node)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
Object.defineProperty(tag, 'name', { value: name })
|
|
33
|
+
|
|
34
|
+
return tag
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const Recommended: TagConstructor = class Recommended implements Tag {
|
|
38
|
+
uuid: string
|
|
39
|
+
title: string
|
|
40
|
+
teaserTitleOverride: string | null
|
|
41
|
+
|
|
42
|
+
static match(node: hast.Element) {
|
|
43
|
+
return node.tagName === 'recommended'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
constructor(node: hast.Element) {
|
|
47
|
+
const ftContent = select('FTContent', node)
|
|
48
|
+
const recommendedTitle = select('RecommendedTitle', node)
|
|
49
|
+
if (
|
|
50
|
+
ftContent?.properties?.url !== undefined &&
|
|
51
|
+
typeof ftContent.properties.url === 'string'
|
|
52
|
+
) {
|
|
53
|
+
this.uuid = uuidFromUrl(ftContent.properties.url)
|
|
54
|
+
} else {
|
|
55
|
+
this.uuid = ''
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.title = recommendedTitle ? toText(recommendedTitle) : 'Recommended'
|
|
59
|
+
this.teaserTitleOverride = ftContent ? toText(ftContent) : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
static resolve = {
|
|
63
|
+
async teaser(parent: Recommended, args, context) {
|
|
64
|
+
const content = await context.dataSources.capi.getContent(parent.uuid)
|
|
65
|
+
return {
|
|
66
|
+
...content,
|
|
67
|
+
title: parent.teaserTitleOverride || content.title,
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static typeDef = gql`
|
|
73
|
+
type Recommended {
|
|
74
|
+
teaser: Content!
|
|
75
|
+
title: String!
|
|
76
|
+
}
|
|
77
|
+
`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ImageSet: TagConstructor = class ImageSet implements Tag {
|
|
81
|
+
static match(node: hast.Element) {
|
|
82
|
+
return (
|
|
83
|
+
node.tagName === 'ft-content' &&
|
|
84
|
+
node.properties?.type === 'http://www.ft.com/ontology/content/ImageSet'
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
url = ''
|
|
89
|
+
imageSet: CAPIImageSet | undefined
|
|
90
|
+
constructor(node: hast.Element, contentApiData?: InternalContent) {
|
|
91
|
+
if (typeof node?.properties?.url === 'string') {
|
|
92
|
+
this.url = node.properties.url ?? ''
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const embed = contentApiData?.embeds?.find(
|
|
96
|
+
(embed: CAPIImageSet) => uuidFromUrl(embed.id) === uuidFromUrl(this.url)
|
|
97
|
+
)
|
|
98
|
+
this.imageSet = embed
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
static resolve = {
|
|
102
|
+
picture: (parent: ImageSet) => parent.imageSet,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
static typeDef = gql`
|
|
106
|
+
type ImageSet {
|
|
107
|
+
picture: Picture!
|
|
108
|
+
}
|
|
109
|
+
`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const FTContent: TagConstructor = class FTContent implements Tag {
|
|
113
|
+
static match(node: hast.Element) {
|
|
114
|
+
return node.tagName === 'ft-content'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
url: string
|
|
118
|
+
type: string
|
|
119
|
+
|
|
120
|
+
constructor(node: hast.Element) {
|
|
121
|
+
if (typeof node?.properties?.url === 'string') {
|
|
122
|
+
this.url = node.properties.url ?? ''
|
|
123
|
+
} else {
|
|
124
|
+
this.url = ''
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (typeof node?.properties?.type === 'string') {
|
|
128
|
+
this.type = node.properties.type ?? ''
|
|
129
|
+
} else {
|
|
130
|
+
this.type = ''
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
static typeDef = gql`
|
|
135
|
+
type FTContent {
|
|
136
|
+
type: String
|
|
137
|
+
url: String
|
|
138
|
+
}
|
|
139
|
+
`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const LayoutImage: TagConstructor = class LayoutImage implements Tag {
|
|
143
|
+
static match(node: hast.Element) {
|
|
144
|
+
return matches('img', node)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
id: string
|
|
148
|
+
src: string
|
|
149
|
+
alt: string
|
|
150
|
+
longDesc: string
|
|
151
|
+
copyright: string
|
|
152
|
+
|
|
153
|
+
constructor(node: hast.Element) {
|
|
154
|
+
this.src = node.properties?.src as string
|
|
155
|
+
this.id = uuidFromUrl(this.src) || ''
|
|
156
|
+
this.alt = node.properties?.alt as string
|
|
157
|
+
this.longDesc = node.properties?.longDesc as string
|
|
158
|
+
this.copyright = node.properties?.dataCopyright as string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static resolve = {
|
|
162
|
+
picture(parent: LayoutImage): CAPIImageSet {
|
|
163
|
+
return {
|
|
164
|
+
id: 'layout-imageset',
|
|
165
|
+
description: parent.alt,
|
|
166
|
+
type: 'http://www.ft.com/ontology/content/ImageSet',
|
|
167
|
+
members: [
|
|
168
|
+
{
|
|
169
|
+
id: parent.id,
|
|
170
|
+
description: parent.alt,
|
|
171
|
+
title: parent.longDesc,
|
|
172
|
+
binaryUrl: parent.src,
|
|
173
|
+
copyright: {
|
|
174
|
+
notice: parent.copyright,
|
|
175
|
+
},
|
|
176
|
+
type: 'http://www.ft.com/ontology/content/Image',
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
static typeDef = gql`
|
|
184
|
+
type LayoutImage {
|
|
185
|
+
picture: Picture!
|
|
186
|
+
}
|
|
187
|
+
`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const Link: TagConstructor = class Link implements Tag {
|
|
191
|
+
href: string
|
|
192
|
+
|
|
193
|
+
static match(node: hast.Element) {
|
|
194
|
+
return node.tagName === 'a'
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
constructor(node: hast.Element) {
|
|
198
|
+
if (typeof node?.properties?.href === 'string') {
|
|
199
|
+
this.href = node.properties.href ?? ''
|
|
200
|
+
} else {
|
|
201
|
+
this.href = ''
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
static typeDef = gql`
|
|
206
|
+
type Link {
|
|
207
|
+
href: String
|
|
208
|
+
}
|
|
209
|
+
`
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const mapping: Record<string, TagConstructor> = {
|
|
213
|
+
Link,
|
|
214
|
+
Experimental: createTagWithoutReference('Experimental', 'experimental'),
|
|
215
|
+
ImageSet,
|
|
216
|
+
FTContent,
|
|
217
|
+
Recommended,
|
|
218
|
+
RecommendedTitle: createTagWithoutReference(
|
|
219
|
+
'RecommendedTitle',
|
|
220
|
+
'recommended-title'
|
|
221
|
+
),
|
|
222
|
+
Paragraph: createTagWithoutReference('Paragraph', 'p'),
|
|
223
|
+
UnorderedList: createTagWithoutReference('UnorderedList', 'ul'),
|
|
224
|
+
ListItem: createTagWithoutReference('ListItem', 'li'),
|
|
225
|
+
Layout: createTagWithoutReference('Layout', '.n-content-layout, .layout'),
|
|
226
|
+
LayoutContainer: createTagWithoutReference(
|
|
227
|
+
'LayoutContainer',
|
|
228
|
+
'.n-content-layout__container, .layout-container'
|
|
229
|
+
),
|
|
230
|
+
LayoutSlot: createTagWithoutReference(
|
|
231
|
+
'LayoutSlot',
|
|
232
|
+
'.n-content-layout__slot, .layout-slot'
|
|
233
|
+
),
|
|
234
|
+
LayoutImage,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export default mapping
|
package/src/topper.ts
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { gql } from 'graphql-tag'
|
|
2
|
+
import { IResolvers } from '@graphql-tools/utils'
|
|
3
|
+
import type { DataSources } from './datasources/index.js'
|
|
4
|
+
import {
|
|
5
|
+
isOpinion,
|
|
6
|
+
getAuthor,
|
|
7
|
+
getDisplayConcept,
|
|
8
|
+
getBrandConcept,
|
|
9
|
+
} from './helpers/metadata.js'
|
|
10
|
+
import imageServiceUrl from './helpers/imageService.js'
|
|
11
|
+
import type {
|
|
12
|
+
InternalContent,
|
|
13
|
+
Image,
|
|
14
|
+
ImageSet,
|
|
15
|
+
} from './types/internal-content.js'
|
|
16
|
+
|
|
17
|
+
const BasicTopperFields = `
|
|
18
|
+
headline: String!
|
|
19
|
+
intro: RichText
|
|
20
|
+
backgroundColour: TopperBackgroundColour
|
|
21
|
+
displayConcept: Concept
|
|
22
|
+
`
|
|
23
|
+
|
|
24
|
+
const TopperWithImagesFields = `
|
|
25
|
+
images: TopperImages
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
const TopperWithThemeFields = `
|
|
29
|
+
isLargeHeadline: Boolean
|
|
30
|
+
layout: String
|
|
31
|
+
`
|
|
32
|
+
|
|
33
|
+
const TopperWithHeadshotFields = `
|
|
34
|
+
headshot(width: Int, dpr: Int): String
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* List of acceptable topper background colours as supported in Origami https://github.com/Financial-Times/origami/tree/main/components/o-topper#colors
|
|
39
|
+
*
|
|
40
|
+
*/
|
|
41
|
+
export const typeDef = gql`
|
|
42
|
+
enum TopperBackgroundColour {
|
|
43
|
+
paper
|
|
44
|
+
wheat
|
|
45
|
+
white
|
|
46
|
+
black
|
|
47
|
+
claret
|
|
48
|
+
oxford
|
|
49
|
+
slate
|
|
50
|
+
crimson
|
|
51
|
+
sky
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type TopperImages {
|
|
55
|
+
standard: Image
|
|
56
|
+
square: Image
|
|
57
|
+
wide: Image
|
|
58
|
+
fallback: Image
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Topper {
|
|
62
|
+
${BasicTopperFields}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface TopperWithImages {
|
|
66
|
+
${TopperWithImagesFields}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface TopperWithTheme {
|
|
70
|
+
${TopperWithThemeFields}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface TopperWithHeadshot {
|
|
74
|
+
${TopperWithHeadshotFields}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
type BasicTopper implements Topper {
|
|
78
|
+
${BasicTopperFields}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
type SplitTextTopper implements Topper & TopperWithImages & TopperWithTheme {
|
|
82
|
+
${BasicTopperFields}
|
|
83
|
+
${TopperWithImagesFields}
|
|
84
|
+
${TopperWithThemeFields}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type FullBleedTopper implements Topper & TopperWithImages & TopperWithTheme {
|
|
88
|
+
${BasicTopperFields}
|
|
89
|
+
${TopperWithImagesFields}
|
|
90
|
+
${TopperWithThemeFields}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type OpinionTopper implements Topper & TopperWithTheme & TopperWithHeadshot {
|
|
94
|
+
${BasicTopperFields}
|
|
95
|
+
${TopperWithThemeFields}
|
|
96
|
+
${TopperWithHeadshotFields}
|
|
97
|
+
columnist: Concept
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type BrandedTopper implements Topper & TopperWithTheme {
|
|
101
|
+
${BasicTopperFields}
|
|
102
|
+
${TopperWithThemeFields}
|
|
103
|
+
brandConcept: Concept
|
|
104
|
+
}
|
|
105
|
+
`
|
|
106
|
+
|
|
107
|
+
export const resolvers: IResolvers<
|
|
108
|
+
unknown,
|
|
109
|
+
{ systemCode: string; dataSources: DataSources },
|
|
110
|
+
never
|
|
111
|
+
> = {
|
|
112
|
+
Topper: {
|
|
113
|
+
__resolveType(content: InternalContent): string {
|
|
114
|
+
if (content.topper?.layout?.startsWith('split')) {
|
|
115
|
+
return 'SplitTextTopper'
|
|
116
|
+
} else if (content.topper?.layout?.startsWith('full-bleed')) {
|
|
117
|
+
return 'FullBleedTopper'
|
|
118
|
+
} else if (isOpinion(content)) {
|
|
119
|
+
return 'OpinionTopper'
|
|
120
|
+
} else if (getBrandConcept(content)) {
|
|
121
|
+
return 'BrandedTopper'
|
|
122
|
+
} else {
|
|
123
|
+
return 'BasicTopper'
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
headline(content: InternalContent): string {
|
|
127
|
+
return content.topper?.headline || content.title
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
intro(content: InternalContent) {
|
|
131
|
+
return content.summary
|
|
132
|
+
? {
|
|
133
|
+
value: content.summary.bodyXML,
|
|
134
|
+
source: 'summary',
|
|
135
|
+
}
|
|
136
|
+
: {
|
|
137
|
+
value: content.standfirst,
|
|
138
|
+
source: 'standfirst',
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
backgroundColour(content: InternalContent): string {
|
|
142
|
+
if (
|
|
143
|
+
content.topper?.backgroundColour &&
|
|
144
|
+
content.topper?.backgroundColour !== 'auto'
|
|
145
|
+
) {
|
|
146
|
+
return content.topper.backgroundColour
|
|
147
|
+
} else {
|
|
148
|
+
return 'paper'
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
displayConcept(content: InternalContent) {
|
|
152
|
+
return getDisplayConcept(content)
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
TopperWithImages: {
|
|
157
|
+
images(content: InternalContent) {
|
|
158
|
+
const groupedImages: { [type: string]: Image } = {}
|
|
159
|
+
|
|
160
|
+
content.leadImages.forEach((leadImage) => {
|
|
161
|
+
groupedImages[leadImage.type] = leadImage.image
|
|
162
|
+
})
|
|
163
|
+
if (
|
|
164
|
+
content.mainImage?.type ===
|
|
165
|
+
'http://www.ft.com/ontology/content/ImageSet'
|
|
166
|
+
) {
|
|
167
|
+
groupedImages.fallback = (content.mainImage as ImageSet).members[0]
|
|
168
|
+
} else if (
|
|
169
|
+
content.mainImage?.type === 'http://www.ft.com/ontology/content/Image'
|
|
170
|
+
) {
|
|
171
|
+
groupedImages.fallback = content.mainImage
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return groupedImages
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
TopperWithTheme: {
|
|
179
|
+
isLargeHeadline: () => false,
|
|
180
|
+
layout(content: InternalContent): string {
|
|
181
|
+
return content.topper?.layout || 'branded'
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
SplitTextTopper: {
|
|
186
|
+
isLargeHeadline: () => true,
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
FullBleedTopper: {
|
|
190
|
+
isLargeHeadline: () => true,
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
OpinionTopper: {
|
|
194
|
+
backgroundColour: () => 'sky',
|
|
195
|
+
async headshot(
|
|
196
|
+
content: InternalContent,
|
|
197
|
+
args,
|
|
198
|
+
context
|
|
199
|
+
): Promise<string | undefined> {
|
|
200
|
+
const author = getAuthor(content)
|
|
201
|
+
if (!author) return
|
|
202
|
+
const authorId = author.id.replace(
|
|
203
|
+
/^https?:\/\/(?:www|api)\.ft\.com\/things?\//,
|
|
204
|
+
''
|
|
205
|
+
)
|
|
206
|
+
const peopleData = await context.dataSources.capi.getPerson(authorId)
|
|
207
|
+
|
|
208
|
+
if (!peopleData || !peopleData._imageUrl) return
|
|
209
|
+
|
|
210
|
+
return imageServiceUrl({
|
|
211
|
+
url: peopleData._imageUrl,
|
|
212
|
+
systemCode: context.systemCode,
|
|
213
|
+
width: args.width || 150,
|
|
214
|
+
dpr: args.dpr,
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
columnist(content: InternalContent) {
|
|
218
|
+
return getAuthor(content)
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
BrandedTopper: {
|
|
223
|
+
backgroundColour: () => 'wheat',
|
|
224
|
+
brandConcept(content: InternalContent) {
|
|
225
|
+
return getBrandConcept(content)
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export type Topper = {
|
|
2
|
+
headline: string
|
|
3
|
+
standfirst: string
|
|
4
|
+
backgroundColour: string
|
|
5
|
+
layout: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type ImageSet = {
|
|
9
|
+
id: string
|
|
10
|
+
type: 'http://www.ft.com/ontology/content/ImageSet'
|
|
11
|
+
description: string
|
|
12
|
+
members: Image[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type LeadImage = {
|
|
16
|
+
type: 'standard' | 'square' | 'wide'
|
|
17
|
+
id: string
|
|
18
|
+
image: Image
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type Annotation = {
|
|
22
|
+
id: string
|
|
23
|
+
apiUrl: string
|
|
24
|
+
directType: string
|
|
25
|
+
predicate: string
|
|
26
|
+
prefLabel: string
|
|
27
|
+
type: string
|
|
28
|
+
types: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type Image = {
|
|
32
|
+
id: string
|
|
33
|
+
type:
|
|
34
|
+
| 'http://www.ft.com/ontology/content/Image'
|
|
35
|
+
| 'http://www.ft.com/ontology/content/Graphic'
|
|
36
|
+
title: string
|
|
37
|
+
description: string
|
|
38
|
+
binaryUrl: string
|
|
39
|
+
copyright?: {
|
|
40
|
+
notice: string
|
|
41
|
+
}
|
|
42
|
+
minDisplayWidth?: string
|
|
43
|
+
maxDisplayWidth?: string
|
|
44
|
+
pixelWidth?: number
|
|
45
|
+
pixelHeight?: number
|
|
46
|
+
}
|
|
47
|
+
export type AccessLevel = 'premium' | 'subscribed' | 'registered' | 'free'
|
|
48
|
+
export type AltTitle = { promotionalTitle?: string }
|
|
49
|
+
export type AltStandfirst = { promotionalStandfirst?: string }
|
|
50
|
+
|
|
51
|
+
export type InternalContent = {
|
|
52
|
+
id: string
|
|
53
|
+
type: string
|
|
54
|
+
types: string[]
|
|
55
|
+
webUrl: string
|
|
56
|
+
title: string
|
|
57
|
+
standfirst: string
|
|
58
|
+
byline: string
|
|
59
|
+
topper?: Topper
|
|
60
|
+
bodyXML: string
|
|
61
|
+
annotations: Annotation[]
|
|
62
|
+
summary?: {
|
|
63
|
+
bodyXML: string
|
|
64
|
+
}
|
|
65
|
+
mainImage: ImageSet | Image
|
|
66
|
+
leadImages: LeadImage[]
|
|
67
|
+
accessLevel: AccessLevel
|
|
68
|
+
embeds: ImageSet[]
|
|
69
|
+
alternativeTitles?: AltTitle
|
|
70
|
+
alternativeStandfirsts?: AltStandfirst
|
|
71
|
+
standout?: {
|
|
72
|
+
editorsChoice: boolean
|
|
73
|
+
exclusive: boolean
|
|
74
|
+
scoop: boolean
|
|
75
|
+
}
|
|
76
|
+
publishedDate: string
|
|
77
|
+
firstPublishedDate: string
|
|
78
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
type ConceptIdMapping = Record<string, string>
|
|
2
|
+
|
|
3
|
+
type ConceptTypeNames =
|
|
4
|
+
| 'genre'
|
|
5
|
+
| 'brand'
|
|
6
|
+
| 'topic'
|
|
7
|
+
| 'person'
|
|
8
|
+
| 'location'
|
|
9
|
+
| 'organisation'
|
|
10
|
+
|
|
11
|
+
type ConceptTypeMappings = Record<ConceptTypeNames, ConceptIDMapping>
|
|
12
|
+
|
|
13
|
+
declare module '@financial-times/n-concept-ids' {
|
|
14
|
+
const map: ConceptTypeMappings
|
|
15
|
+
export default map
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module '@financial-times/n-display-metadata'
|