@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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { InternalContent, Annotation } from '../types/internal-content.js'
|
|
2
|
+
|
|
3
|
+
import conceptIds from '@financial-times/n-concept-ids'
|
|
4
|
+
|
|
5
|
+
export function uuidFromUrl(url: string): string {
|
|
6
|
+
const match = url.match(/(........-....-....-....-............)/)
|
|
7
|
+
|
|
8
|
+
if (match === null) {
|
|
9
|
+
return ''
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return match[1] || ''
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isOpinion(content: InternalContent): boolean {
|
|
16
|
+
return content.annotations.some((annotation) =>
|
|
17
|
+
annotation.id.endsWith(conceptIds.genre.opinion)
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isAuthor(annotation: Annotation) {
|
|
22
|
+
return (
|
|
23
|
+
annotation.predicate === 'http://www.ft.com/ontology/annotation/hasAuthor'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createStreamLink(streamUuid: string) {
|
|
28
|
+
return `https://www.ft.com/stream/uuid/${streamUuid}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function getAuthorUrlMapping(annotations: Annotation[]) {
|
|
32
|
+
const authorAnnotations = getAuthorAnnotations(annotations)
|
|
33
|
+
return authorAnnotations.reduce((mapping, author) => {
|
|
34
|
+
if (author.prefLabel) {
|
|
35
|
+
const uuid = uuidFromUrl(author.id)
|
|
36
|
+
mapping.set(author.prefLabel, createStreamLink(uuid))
|
|
37
|
+
}
|
|
38
|
+
return mapping
|
|
39
|
+
}, new Map<string, string>)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getAuthorAnnotations(annotations: Annotation[]) {
|
|
43
|
+
return annotations.filter(isAuthor)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getAuthor(content: InternalContent): Annotation | undefined {
|
|
47
|
+
return content.annotations.find((annotation) => isAuthor(annotation))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isColumn(content: InternalContent): boolean {
|
|
51
|
+
return isOpinion(content) && !!getAuthor(content)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getDisplayConcept(
|
|
55
|
+
content: InternalContent
|
|
56
|
+
): Annotation | undefined {
|
|
57
|
+
return content.annotations.find(
|
|
58
|
+
(annotation) =>
|
|
59
|
+
annotation.predicate === 'http://www.ft.com/ontology/hasDisplayTag'
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getBrandConcept(
|
|
64
|
+
content: InternalContent
|
|
65
|
+
): Annotation | undefined {
|
|
66
|
+
return content.annotations.find(
|
|
67
|
+
(annotation) =>
|
|
68
|
+
annotation.predicate ===
|
|
69
|
+
'http://www.ft.com/ontology/classification/isClassifiedBy' &&
|
|
70
|
+
annotation.directType === 'http://www.ft.com/ontology/product/Brand'
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isPodcast(content: InternalContent): boolean {
|
|
75
|
+
return content.types.includes('http://www.ft.com/ontology/content/Audio')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isEditorsChoice(content: InternalContent): boolean {
|
|
79
|
+
return !!content.standout?.editorsChoice
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isExclusive(content: InternalContent): boolean {
|
|
83
|
+
return !!content.standout?.exclusive
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function isScoop(content: InternalContent): boolean {
|
|
87
|
+
return !!content.standout?.scoop
|
|
88
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function buildUrlTree(
|
|
2
|
+
urlMapping: Map<string, string>,
|
|
3
|
+
parts: string[]
|
|
4
|
+
) {
|
|
5
|
+
const children = parts.map((part) => {
|
|
6
|
+
const url = urlMapping.get(part)
|
|
7
|
+
|
|
8
|
+
if (url) {
|
|
9
|
+
return {
|
|
10
|
+
type: 'element',
|
|
11
|
+
tagName: 'Link',
|
|
12
|
+
properties: { href: url },
|
|
13
|
+
children: [{ type: 'text', value: part }],
|
|
14
|
+
}
|
|
15
|
+
} else {
|
|
16
|
+
return {
|
|
17
|
+
type: 'text',
|
|
18
|
+
value: part,
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
tree: { type: 'root', children },
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { resolvers, ImageType } from './image.js'
|
|
2
|
+
import type { ImageSourceArgs, DisplayWidthArgs } from './image.js'
|
|
3
|
+
import type { Image } from './types/internal-content.js'
|
|
4
|
+
import { jest } from '@jest/globals'
|
|
5
|
+
import { IObjectTypeResolver } from '@graphql-tools/utils'
|
|
6
|
+
|
|
7
|
+
//TODO:AG:20220908 Replace this with generated schema types
|
|
8
|
+
type ImageSource = {
|
|
9
|
+
url: string
|
|
10
|
+
width?: number
|
|
11
|
+
dpr?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('Image', () => {
|
|
15
|
+
const image = {
|
|
16
|
+
id: 'https://api.ft.com/content/00000000-0000-0000-0000-000000000001',
|
|
17
|
+
type: ImageType.Image,
|
|
18
|
+
title: 'title',
|
|
19
|
+
description: 'description',
|
|
20
|
+
binaryUrl: 'cloudfront.com/image',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const graphic = {
|
|
24
|
+
...image,
|
|
25
|
+
type: ImageType.Graphic,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const getImageMetadata = jest.fn()
|
|
29
|
+
|
|
30
|
+
const context = {
|
|
31
|
+
dataSources: {
|
|
32
|
+
origami: {
|
|
33
|
+
getImageMetadata: getImageMetadata,
|
|
34
|
+
},
|
|
35
|
+
capi: {},
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
getImageMetadata.mockReset()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('sources resolver', () => {
|
|
44
|
+
type ResolverType = (
|
|
45
|
+
root: Image,
|
|
46
|
+
args: ImageSourceArgs,
|
|
47
|
+
context: unknown
|
|
48
|
+
) => Promise<ImageSource[]>
|
|
49
|
+
|
|
50
|
+
const sourcesResolver = (resolvers.Image as IObjectTypeResolver)
|
|
51
|
+
.sources as ResolverType
|
|
52
|
+
|
|
53
|
+
describe('An image that is provided with a high resolution', () => {
|
|
54
|
+
let sources: ImageSource[]
|
|
55
|
+
|
|
56
|
+
beforeAll(async () => {
|
|
57
|
+
getImageMetadata.mockReturnValue({ width: 5000, height: 1000 })
|
|
58
|
+
sources = await sourcesResolver(
|
|
59
|
+
image,
|
|
60
|
+
{
|
|
61
|
+
width: 1000,
|
|
62
|
+
},
|
|
63
|
+
context
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('transforms the URL to use the Origami Image Service', () => {
|
|
68
|
+
expect(
|
|
69
|
+
sources.every((source) =>
|
|
70
|
+
source.url.startsWith(
|
|
71
|
+
'https://www.ft.com/__origami/service/image/v2'
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
).toBeTruthy()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('resizes the image to the requested width', () => {
|
|
78
|
+
expect(
|
|
79
|
+
sources.every(
|
|
80
|
+
(source) =>
|
|
81
|
+
source.width === 1000 && source.url.includes('width=1000')
|
|
82
|
+
)
|
|
83
|
+
).toBeTruthy()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('includes sources for all the possible resolutions we can display the image at, given the requested width and the original source width', () => {
|
|
87
|
+
expect(sources.length).toEqual(5)
|
|
88
|
+
expect(sources[0].dpr).toEqual(1)
|
|
89
|
+
expect(sources[0].url).toMatch(/dpr=1/)
|
|
90
|
+
expect(sources[4].dpr).toEqual(5)
|
|
91
|
+
expect(sources[4].url).toMatch(/dpr=5/)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe("An image that that isn't wide enough to generate high-resolution sources for", () => {
|
|
96
|
+
it('returns an array with a single resolution iamge service UR', async () => {
|
|
97
|
+
getImageMetadata.mockReturnValue({ width: 1500, height: 500 })
|
|
98
|
+
const sources = await sourcesResolver(
|
|
99
|
+
image,
|
|
100
|
+
{
|
|
101
|
+
width: 1000,
|
|
102
|
+
},
|
|
103
|
+
context
|
|
104
|
+
)
|
|
105
|
+
expect(sources.length).toEqual(1)
|
|
106
|
+
expect(sources[0]).toEqual({
|
|
107
|
+
dpr: 1,
|
|
108
|
+
url: 'https://www.ft.com/__origami/service/image/v2/images/raw/ftcms%3A00000000-0000-0000-0000-000000000001?source=undefined&fit=scale-down&quality=highest&width=1000&dpr=1',
|
|
109
|
+
width: 1000,
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('An image that that is smaller than the width that was requested', () => {
|
|
115
|
+
it('returns the image at the same width as the original', async () => {
|
|
116
|
+
getImageMetadata.mockReturnValue({ width: 500, height: 500 })
|
|
117
|
+
const sources = await sourcesResolver(
|
|
118
|
+
image,
|
|
119
|
+
{
|
|
120
|
+
width: 1000,
|
|
121
|
+
},
|
|
122
|
+
context
|
|
123
|
+
)
|
|
124
|
+
expect(sources.length).toEqual(1)
|
|
125
|
+
expect(sources[0]).toEqual({
|
|
126
|
+
dpr: 1,
|
|
127
|
+
url: 'https://www.ft.com/__origami/service/image/v2/images/raw/ftcms%3A00000000-0000-0000-0000-000000000001?source=undefined&fit=scale-down&quality=highest&width=500&dpr=1',
|
|
128
|
+
width: 500,
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe('A large image with the maxDpr argumet passed', () => {
|
|
134
|
+
it('only returns sources up to and including the maxDpr', async () => {
|
|
135
|
+
getImageMetadata.mockReturnValue({ width: 5000, height: 500 })
|
|
136
|
+
const sources = await sourcesResolver(
|
|
137
|
+
image,
|
|
138
|
+
{
|
|
139
|
+
width: 1000,
|
|
140
|
+
maxDpr: 2,
|
|
141
|
+
},
|
|
142
|
+
context
|
|
143
|
+
)
|
|
144
|
+
expect(sources.length).toEqual(2)
|
|
145
|
+
expect(sources[0].dpr).toEqual(1)
|
|
146
|
+
expect(sources[1].dpr).toEqual(2)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('An invalid image object', () => {
|
|
151
|
+
it('throws an error', async () => {
|
|
152
|
+
const invalidImage = {
|
|
153
|
+
type: ImageType.Image,
|
|
154
|
+
id: 'not-a-uuid',
|
|
155
|
+
title: '',
|
|
156
|
+
description: '',
|
|
157
|
+
binaryUrl: '',
|
|
158
|
+
}
|
|
159
|
+
await expect(
|
|
160
|
+
sourcesResolver(
|
|
161
|
+
invalidImage,
|
|
162
|
+
{
|
|
163
|
+
width: 1000,
|
|
164
|
+
maxDpr: 2,
|
|
165
|
+
},
|
|
166
|
+
context
|
|
167
|
+
)
|
|
168
|
+
).rejects.toThrow('not-a-uuid is not a valid Content API Image ID')
|
|
169
|
+
expect(getImageMetadata).not.toHaveBeenCalled()
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
describe('When the image service fails to return metadata', () => {
|
|
174
|
+
let sources: ImageSource[]
|
|
175
|
+
beforeEach(async () => {
|
|
176
|
+
getImageMetadata.mockRejectedValue(null)
|
|
177
|
+
|
|
178
|
+
sources = await sourcesResolver(
|
|
179
|
+
image,
|
|
180
|
+
{
|
|
181
|
+
width: 3000,
|
|
182
|
+
},
|
|
183
|
+
context
|
|
184
|
+
)
|
|
185
|
+
})
|
|
186
|
+
it('passes the requested width to the image service', async () => {
|
|
187
|
+
expect(sources.length).toEqual(1)
|
|
188
|
+
expect(sources[0].width).toEqual(3000)
|
|
189
|
+
expect(sources[0].url).toMatch(/width=3000/)
|
|
190
|
+
})
|
|
191
|
+
it('falls back to a single DPR source', async () => {
|
|
192
|
+
expect(sources.length).toEqual(1)
|
|
193
|
+
expect(sources[0].dpr).toEqual(1)
|
|
194
|
+
expect(sources[0].url).toMatch(/dpr=1/)
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
describe('originalWidth/originalHeight resolvers', () => {
|
|
200
|
+
type ResolverType = (
|
|
201
|
+
root: Image,
|
|
202
|
+
args: undefined,
|
|
203
|
+
context: unknown
|
|
204
|
+
) => Promise<number | null>
|
|
205
|
+
|
|
206
|
+
const originalWidthResolver = (resolvers.Image as IObjectTypeResolver)
|
|
207
|
+
.originalWidth as ResolverType
|
|
208
|
+
const originalHeightResolver = (resolvers.Image as IObjectTypeResolver)
|
|
209
|
+
.originalHeight as ResolverType
|
|
210
|
+
|
|
211
|
+
it('returns the original width and height from the image service metadata', async () => {
|
|
212
|
+
getImageMetadata.mockReturnValue({ width: 100, height: 200 })
|
|
213
|
+
const width = await originalWidthResolver(image, undefined, context)
|
|
214
|
+
const height = await originalHeightResolver(image, undefined, context)
|
|
215
|
+
expect(width).toEqual(100)
|
|
216
|
+
expect(height).toEqual(200)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('returns null if the image service metadata call fails', async () => {
|
|
220
|
+
getImageMetadata.mockRejectedValue(null)
|
|
221
|
+
const width = await originalWidthResolver(image, undefined, context)
|
|
222
|
+
const height = await originalHeightResolver(image, undefined, context)
|
|
223
|
+
expect(width).toEqual(null)
|
|
224
|
+
expect(height).toEqual(null)
|
|
225
|
+
})
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe('minDisplayWidth/maxDisplayWidth resolvers', () => {
|
|
229
|
+
type ResolverType = (
|
|
230
|
+
root: Image,
|
|
231
|
+
args?: DisplayWidthArgs
|
|
232
|
+
) => Promise<number | null>
|
|
233
|
+
|
|
234
|
+
const minDisplayWidthResolver = (resolvers.Image as IObjectTypeResolver)
|
|
235
|
+
.minDisplayWidth as ResolverType
|
|
236
|
+
const maxDisplayWidthResolver = (resolvers.Image as IObjectTypeResolver)
|
|
237
|
+
.maxDisplayWidth as ResolverType
|
|
238
|
+
|
|
239
|
+
it('uses the argument value if supplied, so consumers can control when an image should be displayed', () => {
|
|
240
|
+
const imageWithDisplayWidth = {
|
|
241
|
+
...image,
|
|
242
|
+
minDisplayWidth: '980px',
|
|
243
|
+
}
|
|
244
|
+
const minDisplayWidth = minDisplayWidthResolver(imageWithDisplayWidth, {
|
|
245
|
+
width: 500,
|
|
246
|
+
})
|
|
247
|
+
const maxDisplayWidth = maxDisplayWidthResolver(imageWithDisplayWidth, {
|
|
248
|
+
width: 900,
|
|
249
|
+
})
|
|
250
|
+
expect(minDisplayWidth).toEqual('500px')
|
|
251
|
+
expect(maxDisplayWidth).toEqual('900px')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('uses the values from the content API if no overrides supplied', () => {
|
|
255
|
+
const imageWithDisplayWidth = {
|
|
256
|
+
...image,
|
|
257
|
+
minDisplayWidth: '490px',
|
|
258
|
+
maxDisplayWidth: '980px',
|
|
259
|
+
}
|
|
260
|
+
const minDisplayWidth = minDisplayWidthResolver(imageWithDisplayWidth, {})
|
|
261
|
+
const maxDisplayWidth = maxDisplayWidthResolver(imageWithDisplayWidth, {})
|
|
262
|
+
expect(minDisplayWidth).toEqual('490px')
|
|
263
|
+
expect(maxDisplayWidth).toEqual('980px')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('returns undefined if no values supplied or in content API response ', () => {
|
|
267
|
+
const minDisplayWidth = minDisplayWidthResolver(image, {})
|
|
268
|
+
const maxDisplayWidth = maxDisplayWidthResolver(image, {})
|
|
269
|
+
expect(minDisplayWidth).toBeUndefined()
|
|
270
|
+
expect(maxDisplayWidth).toBeUndefined()
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('caption resolver', () => {
|
|
275
|
+
type ResolverType = (root: Image) => Promise<string | null>
|
|
276
|
+
|
|
277
|
+
const captionResolver = (resolvers.Image as IObjectTypeResolver)
|
|
278
|
+
.caption as ResolverType
|
|
279
|
+
|
|
280
|
+
it('combines the caption and copyright together', () => {
|
|
281
|
+
const imageWithCopyright = { ...image, copyright: { notice: '© credit' } }
|
|
282
|
+
const caption = captionResolver(imageWithCopyright)
|
|
283
|
+
expect(caption).toBe('title © credit')
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('returns just the caption if there is no copright', () => {
|
|
287
|
+
const caption = captionResolver(image)
|
|
288
|
+
expect(caption).toBe('title')
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
describe('copyright resolver', () => {
|
|
293
|
+
type ResolverType = (root: Image) => Promise<string | null>
|
|
294
|
+
|
|
295
|
+
const copyrightResolver = (resolvers.Image as IObjectTypeResolver)
|
|
296
|
+
.copyright as ResolverType
|
|
297
|
+
|
|
298
|
+
it('returns the copyright notice if it exists', () => {
|
|
299
|
+
const imageWithCopyright = { ...image, copyright: { notice: '© credit' } }
|
|
300
|
+
const copyright = copyrightResolver(imageWithCopyright)
|
|
301
|
+
expect(copyright).toBe('© credit')
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('returns undefined if no copyright', () => {
|
|
305
|
+
const copyright = copyrightResolver(image)
|
|
306
|
+
expect(copyright).toBe(undefined)
|
|
307
|
+
})
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
describe('alt resolver', () => {
|
|
311
|
+
type ResolverType = (root: Image) => Promise<string | null>
|
|
312
|
+
|
|
313
|
+
const altResolver = (resolvers.Image as IObjectTypeResolver)
|
|
314
|
+
.alt as ResolverType
|
|
315
|
+
|
|
316
|
+
it('uses the description if it exists', () => {
|
|
317
|
+
const alt = altResolver(image)
|
|
318
|
+
expect(alt).toBe('description')
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('escapes double quotes', () => {
|
|
322
|
+
const alt = altResolver({
|
|
323
|
+
...image,
|
|
324
|
+
description: 'This is a "quoted" description',
|
|
325
|
+
})
|
|
326
|
+
expect(alt).toBe('This is a "quoted" description')
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
it("returns an empty string for images with no description, so screen readers don't read out the URL", () => {
|
|
330
|
+
const alt = altResolver({ ...image, description: '' })
|
|
331
|
+
expect(alt).toBe('')
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
it('returns an default alt text for graphics with no description, as they are not considered presentational', () => {
|
|
335
|
+
const alt = altResolver({ ...graphic, description: '' })
|
|
336
|
+
expect(alt).toBe('A graphic with no description')
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
})
|
package/src/image.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { gql } from 'graphql-tag'
|
|
2
|
+
import { IResolvers } from '@graphql-tools/utils'
|
|
3
|
+
import imageServiceUrl from './helpers/imageService.js'
|
|
4
|
+
import type { DataSources } from './datasources/index.js'
|
|
5
|
+
import { uuidFromUrl } from './helpers/metadata.js'
|
|
6
|
+
import type { Image } from './types/internal-content.js'
|
|
7
|
+
|
|
8
|
+
type ImageDimensions = {
|
|
9
|
+
width: number
|
|
10
|
+
height: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ImageSourceArgs = {
|
|
14
|
+
width: number
|
|
15
|
+
maxDpr?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type DisplayWidthArgs = {
|
|
19
|
+
width?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const ImageType = {
|
|
23
|
+
Image: 'http://www.ft.com/ontology/content/Image',
|
|
24
|
+
Graphic: 'http://www.ft.com/ontology/content/Graphic',
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
export const typeDef = gql`
|
|
28
|
+
enum ImageType {
|
|
29
|
+
Image
|
|
30
|
+
Graphic
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type ImageSource {
|
|
34
|
+
url: String!
|
|
35
|
+
width: Int
|
|
36
|
+
dpr: Int
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Image {
|
|
40
|
+
type: ImageType
|
|
41
|
+
sources(width: Int, maxDpr: Int): [ImageSource!]!
|
|
42
|
+
binaryUrl: String
|
|
43
|
+
originalWidth: Int
|
|
44
|
+
originalHeight: Int
|
|
45
|
+
copyright: String
|
|
46
|
+
minDisplayWidth(
|
|
47
|
+
"""
|
|
48
|
+
min-width (in pixels) for this image to be displayed.
|
|
49
|
+
Overrides any value coming from the Content API
|
|
50
|
+
"""
|
|
51
|
+
width: Int
|
|
52
|
+
): String
|
|
53
|
+
maxDisplayWidth(width: Int): String
|
|
54
|
+
caption: String!
|
|
55
|
+
alt: String!
|
|
56
|
+
}
|
|
57
|
+
`
|
|
58
|
+
|
|
59
|
+
export async function getImageDimensions(
|
|
60
|
+
image: Image,
|
|
61
|
+
dataSources: DataSources
|
|
62
|
+
): Promise<ImageDimensions> {
|
|
63
|
+
if (image.pixelWidth && image.pixelHeight) {
|
|
64
|
+
return { width: image.pixelWidth, height: image.pixelHeight }
|
|
65
|
+
}
|
|
66
|
+
const binaryUrl = image.binaryUrl
|
|
67
|
+
const imageMetadata = await dataSources.origami.getImageMetadata(binaryUrl)
|
|
68
|
+
return imageMetadata
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const resolvers: IResolvers<
|
|
72
|
+
unknown,
|
|
73
|
+
{ systemCode: string; dataSources: DataSources },
|
|
74
|
+
never
|
|
75
|
+
> = {
|
|
76
|
+
Image: {
|
|
77
|
+
async sources(parent: Image, args: ImageSourceArgs, context) {
|
|
78
|
+
const id = uuidFromUrl(parent.id)
|
|
79
|
+
if (!id) {
|
|
80
|
+
throw new Error(`${parent.id} is not a valid Content API Image ID`)
|
|
81
|
+
}
|
|
82
|
+
const requestedWidth = args.width || 700
|
|
83
|
+
let maxAllowedWidth = requestedWidth
|
|
84
|
+
let maxDpr = 1
|
|
85
|
+
try {
|
|
86
|
+
const dimensions = await getImageDimensions(parent, context.dataSources)
|
|
87
|
+
maxAllowedWidth = Math.min(requestedWidth, dimensions.width)
|
|
88
|
+
maxDpr = Math.min(
|
|
89
|
+
args.maxDpr || Infinity,
|
|
90
|
+
Math.floor(dimensions.width / maxAllowedWidth)
|
|
91
|
+
)
|
|
92
|
+
} catch (err) {
|
|
93
|
+
//TODO: log error
|
|
94
|
+
}
|
|
95
|
+
const resolutions = [...Array(maxDpr + 1).keys()].slice(1)
|
|
96
|
+
|
|
97
|
+
return resolutions.map((dpr) => ({
|
|
98
|
+
url: imageServiceUrl({
|
|
99
|
+
id,
|
|
100
|
+
url: parent.binaryUrl,
|
|
101
|
+
systemCode: context.systemCode,
|
|
102
|
+
width: maxAllowedWidth,
|
|
103
|
+
dpr,
|
|
104
|
+
}),
|
|
105
|
+
width: maxAllowedWidth,
|
|
106
|
+
dpr,
|
|
107
|
+
}))
|
|
108
|
+
},
|
|
109
|
+
async originalWidth(parent: Image, args, context) {
|
|
110
|
+
try {
|
|
111
|
+
const dimensions = await getImageDimensions(parent, context.dataSources)
|
|
112
|
+
return dimensions.width
|
|
113
|
+
} catch (err) {
|
|
114
|
+
//TODO: log error
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
async originalHeight(parent: Image, args, context) {
|
|
119
|
+
try {
|
|
120
|
+
const dimensions = await getImageDimensions(parent, context.dataSources)
|
|
121
|
+
return dimensions.height
|
|
122
|
+
} catch (err) {
|
|
123
|
+
//TODO: log error
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
copyright(parent: Image) {
|
|
129
|
+
return parent.copyright?.notice
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
minDisplayWidth(parent: Image, args: DisplayWidthArgs) {
|
|
133
|
+
return args.width ? `${args.width}px` : parent.minDisplayWidth
|
|
134
|
+
},
|
|
135
|
+
maxDisplayWidth(parent: Image, args: DisplayWidthArgs) {
|
|
136
|
+
return args.width ? `${args.width}px` : parent.maxDisplayWidth
|
|
137
|
+
},
|
|
138
|
+
|
|
139
|
+
caption(parent: Image) {
|
|
140
|
+
const elements = [parent.title, parent.copyright?.notice].filter(Boolean)
|
|
141
|
+
return elements.length && elements.join(' ')
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
alt(parent: Image) {
|
|
145
|
+
const fallback =
|
|
146
|
+
parent.type === ImageType.Graphic ? 'A graphic with no description' : ''
|
|
147
|
+
return (parent.description || fallback).replace(/"/g, '"')
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
ImageType: {
|
|
151
|
+
Image: ImageType.Image,
|
|
152
|
+
Graphic: ImageType.Graphic,
|
|
153
|
+
},
|
|
154
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { IResolvers } from '@graphql-tools/utils'
|
|
2
|
+
import { DocumentNode } from 'graphql'
|
|
3
|
+
import { gql } from 'graphql-tag'
|
|
4
|
+
|
|
5
|
+
import { dataSources, DataSources } from './datasources/index.js'
|
|
6
|
+
|
|
7
|
+
import { typeDef as Content, resolvers as contentResolvers } from './content.js'
|
|
8
|
+
import { typeDef as Concept, resolvers as conceptResolvers } from './concept.js'
|
|
9
|
+
import { typeDef as Picture, resolvers as pictureResolvers } from './picture.js'
|
|
10
|
+
import { typeDef as Image, resolvers as imageResolvers } from './image.js'
|
|
11
|
+
import { typeDef as Topper, resolvers as topperResolvers } from './topper.js'
|
|
12
|
+
import {
|
|
13
|
+
typeDef as RichText,
|
|
14
|
+
resolvers as richTextResolvers,
|
|
15
|
+
} from './richText.js'
|
|
16
|
+
|
|
17
|
+
import tags from './tags.js'
|
|
18
|
+
|
|
19
|
+
const tagEntries = Object.entries(tags)
|
|
20
|
+
const tagsWithReferences = tagEntries.filter(([_key, tag]) => tag.typeDef)
|
|
21
|
+
const tagTypeDefs = tagsWithReferences.flatMap(
|
|
22
|
+
([_key, tag]) => tag.typeDef as DocumentNode
|
|
23
|
+
)
|
|
24
|
+
const Reference = gql`union Reference = ${tagsWithReferences
|
|
25
|
+
.flatMap(([key]) => key)
|
|
26
|
+
.join(' | ')}`
|
|
27
|
+
|
|
28
|
+
const tagResolvers = Object.fromEntries(
|
|
29
|
+
tagEntries.flatMap(([key, tag]) => (tag.resolve ? [[key, tag.resolve]] : []))
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
// A schema is a collection of type definitions (hence "typeDefs")
|
|
33
|
+
// that together define the "shape" of queries that are executed against
|
|
34
|
+
// your data.
|
|
35
|
+
const coreTypeDefs = gql`
|
|
36
|
+
# Comments in GraphQL strings (such as this one) start with the hash (#) symbol.
|
|
37
|
+
|
|
38
|
+
scalar JSON
|
|
39
|
+
|
|
40
|
+
# The "Query" type is special: it lists all of the available queries that
|
|
41
|
+
# clients can execute, along with the return type for each. In this
|
|
42
|
+
# case, the "books" query returns an array of zero or more Books (defined above).
|
|
43
|
+
type Query {
|
|
44
|
+
content(uuid: String!): Content!
|
|
45
|
+
contentFromJSON(content: JSON!): Content!
|
|
46
|
+
}
|
|
47
|
+
`
|
|
48
|
+
// Resolvers define the technique for fetching the types defined in the
|
|
49
|
+
// schema. This resolver retrieves books from the "books" array above.
|
|
50
|
+
export const resolvers: IResolvers<unknown, { dataSources: DataSources }> = {
|
|
51
|
+
Query: {
|
|
52
|
+
async content(parent, args, context) {
|
|
53
|
+
return context.dataSources.capi.getContent(args.uuid)
|
|
54
|
+
},
|
|
55
|
+
contentFromJSON(_, { content }) {
|
|
56
|
+
return content
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
Reference: {
|
|
61
|
+
__resolveType(obj: { constructor: { name: string } }) {
|
|
62
|
+
return obj.constructor.name
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
...conceptResolvers,
|
|
67
|
+
...contentResolvers,
|
|
68
|
+
...pictureResolvers,
|
|
69
|
+
...imageResolvers,
|
|
70
|
+
...tagResolvers,
|
|
71
|
+
...topperResolvers,
|
|
72
|
+
...richTextResolvers,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const typeDefs = [
|
|
76
|
+
Content,
|
|
77
|
+
Concept,
|
|
78
|
+
Picture,
|
|
79
|
+
Image,
|
|
80
|
+
Topper,
|
|
81
|
+
RichText,
|
|
82
|
+
Reference,
|
|
83
|
+
tagTypeDefs,
|
|
84
|
+
coreTypeDefs,
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
export { dataSources, DataSources, typeDefs as schema }
|