@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.
Files changed (100) hide show
  1. package/.toolkitrc.yml +12 -0
  2. package/CHANGELOG.md +72 -0
  3. package/jest.config.js +3 -0
  4. package/lib/concept.d.ts +7 -0
  5. package/lib/concept.js +39 -0
  6. package/lib/concept.js.map +1 -0
  7. package/lib/constants/contentTypes.d.ts +2 -0
  8. package/lib/constants/contentTypes.js +3 -0
  9. package/lib/constants/contentTypes.js.map +1 -0
  10. package/lib/content.d.ts +55 -0
  11. package/lib/content.js +133 -0
  12. package/lib/content.js.map +1 -0
  13. package/lib/content.test.d.ts +1 -0
  14. package/lib/content.test.js +149 -0
  15. package/lib/content.test.js.map +1 -0
  16. package/lib/datasources/capi.d.ts +10 -0
  17. package/lib/datasources/capi.js +28 -0
  18. package/lib/datasources/capi.js.map +1 -0
  19. package/lib/datasources/index.d.ts +9 -0
  20. package/lib/datasources/index.js +9 -0
  21. package/lib/datasources/index.js.map +1 -0
  22. package/lib/datasources/origami-image.d.ts +8 -0
  23. package/lib/datasources/origami-image.js +11 -0
  24. package/lib/datasources/origami-image.js.map +1 -0
  25. package/lib/datasources/url-management.d.ts +11 -0
  26. package/lib/datasources/url-management.js +40 -0
  27. package/lib/datasources/url-management.js.map +1 -0
  28. package/lib/datasources/url-management.test.d.ts +1 -0
  29. package/lib/datasources/url-management.test.js +69 -0
  30. package/lib/datasources/url-management.test.js.map +1 -0
  31. package/lib/helpers/byline.d.ts +1 -0
  32. package/lib/helpers/byline.js +5 -0
  33. package/lib/helpers/byline.js.map +1 -0
  34. package/lib/helpers/imageService.d.ts +8 -0
  35. package/lib/helpers/imageService.js +13 -0
  36. package/lib/helpers/imageService.js.map +1 -0
  37. package/lib/helpers/metadata.d.ts +12 -0
  38. package/lib/helpers/metadata.js +60 -0
  39. package/lib/helpers/metadata.js.map +1 -0
  40. package/lib/helpers/syntaxTree.d.ts +23 -0
  41. package/lib/helpers/syntaxTree.js +23 -0
  42. package/lib/helpers/syntaxTree.js.map +1 -0
  43. package/lib/image.d.ts +25 -0
  44. package/lib/image.js +123 -0
  45. package/lib/image.js.map +1 -0
  46. package/lib/image.test.d.ts +1 -0
  47. package/lib/image.test.js +235 -0
  48. package/lib/image.test.js.map +1 -0
  49. package/lib/index.d.ts +8 -0
  50. package/lib/index.js +69 -0
  51. package/lib/index.js.map +1 -0
  52. package/lib/picture.d.ts +22 -0
  53. package/lib/picture.js +80 -0
  54. package/lib/picture.js.map +1 -0
  55. package/lib/richText.d.ts +14 -0
  56. package/lib/richText.js +48 -0
  57. package/lib/richText.js.map +1 -0
  58. package/lib/tags.d.ts +13 -0
  59. package/lib/tags.js +178 -0
  60. package/lib/tags.js.map +1 -0
  61. package/lib/topper.d.ts +7 -0
  62. package/lib/topper.js +196 -0
  63. package/lib/topper.js.map +1 -0
  64. package/lib/unified-plugins/extract-references.d.ts +7 -0
  65. package/lib/unified-plugins/extract-references.js +36 -0
  66. package/lib/unified-plugins/extract-references.js.map +1 -0
  67. package/lib/unified-plugins/map-to-abstract-types.d.ts +4 -0
  68. package/lib/unified-plugins/map-to-abstract-types.js +17 -0
  69. package/lib/unified-plugins/map-to-abstract-types.js.map +1 -0
  70. package/package.json +43 -0
  71. package/src/__snapshots__/content.test.ts.snap +118 -0
  72. package/src/concept.ts +58 -0
  73. package/src/constants/contentTypes.ts +4 -0
  74. package/src/content.test.ts +163 -0
  75. package/src/content.ts +146 -0
  76. package/src/datasources/capi.ts +28 -0
  77. package/src/datasources/index.ts +11 -0
  78. package/src/datasources/origami-image.ts +10 -0
  79. package/src/datasources/url-management.test.ts +92 -0
  80. package/src/datasources/url-management.ts +65 -0
  81. package/src/helpers/byline.ts +4 -0
  82. package/src/helpers/imageService.ts +31 -0
  83. package/src/helpers/metadata.ts +88 -0
  84. package/src/helpers/syntaxTree.ts +26 -0
  85. package/src/image.test.ts +339 -0
  86. package/src/image.ts +154 -0
  87. package/src/index.ts +87 -0
  88. package/src/picture.ts +98 -0
  89. package/src/richText.ts +62 -0
  90. package/src/tags.ts +237 -0
  91. package/src/topper.ts +228 -0
  92. package/src/types/internal-content.d.ts +78 -0
  93. package/src/types/n-concept-ids.d.ts +16 -0
  94. package/src/types/n-display-metadata.d.ts +1 -0
  95. package/src/types/n-url-management-api-read-client.d.ts +11 -0
  96. package/src/types/next-metrics.d.ts +1 -0
  97. package/src/unified-plugins/extract-references.ts +50 -0
  98. package/src/unified-plugins/map-to-abstract-types.ts +21 -0
  99. package/tsconfig.json +10 -0
  100. 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 &quot;quoted&quot; 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, '&quot;')
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 }