@financial-times/cp-content-pipeline-schema 1.3.0 → 1.3.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 (42) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/lib/generated/index.d.ts +12 -3
  3. package/lib/helpers/decorateHeadshotUrl.d.ts +3 -0
  4. package/lib/helpers/decorateHeadshotUrl.js +39 -0
  5. package/lib/helpers/decorateHeadshotUrl.js.map +1 -0
  6. package/lib/model/Concept.js +44 -14
  7. package/lib/model/Concept.js.map +1 -1
  8. package/lib/model/Concept.test.js +2 -2
  9. package/lib/model/Concept.test.js.map +1 -1
  10. package/lib/model/Picture.js +2 -0
  11. package/lib/model/Picture.js.map +1 -1
  12. package/lib/model/Topper.d.ts +1 -1
  13. package/lib/model/Topper.js +18 -11
  14. package/lib/model/Topper.js.map +1 -1
  15. package/lib/model/schemas/capi/article.d.ts +5 -0
  16. package/lib/model/schemas/capi/audio.d.ts +5 -0
  17. package/lib/model/schemas/capi/base-schema.d.ts +32 -0
  18. package/lib/model/schemas/capi/base-schema.js +1 -0
  19. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  20. package/lib/model/schemas/capi/content-package.d.ts +5 -0
  21. package/lib/model/schemas/capi/live-blog-package.d.ts +5 -0
  22. package/lib/model/schemas/capi/placeholder.d.ts +5 -0
  23. package/lib/resolvers/content-tree/references/ImageSet.js +1 -1
  24. package/lib/resolvers/content-tree/references/ImageSet.js.map +1 -1
  25. package/lib/resolvers/content-tree/tagMappings.js +2 -2
  26. package/lib/resolvers/index.d.ts +3 -2
  27. package/lib/resolvers/topper.d.ts +3 -2
  28. package/lib/resolvers/topper.js +2 -3
  29. package/lib/resolvers/topper.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/generated/index.ts +9 -3
  32. package/src/helpers/decorateHeadshotUrl.ts +47 -0
  33. package/src/model/Concept.test.ts +2 -4
  34. package/src/model/Concept.ts +22 -17
  35. package/src/model/Picture.ts +3 -0
  36. package/src/model/Topper.ts +26 -12
  37. package/src/model/schemas/capi/base-schema.ts +1 -0
  38. package/src/resolvers/content-tree/references/ImageSet.ts +1 -1
  39. package/src/resolvers/content-tree/tagMappings.ts +2 -2
  40. package/src/resolvers/topper.ts +2 -3
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/typedefs/topper.graphql +3 -0
@@ -0,0 +1,47 @@
1
+ import { CapiPerson } from '../types/internal-content'
2
+
3
+ export const UUID_REGEX =
4
+ /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-9][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i
5
+
6
+ const IMAGESET_REGEX = /fthead(?:-v\d)?\:[^?]+/
7
+
8
+ export default function decorateHeadshotUrl(person: CapiPerson) {
9
+ const url = person?._imageUrl
10
+
11
+ if (!url) return null
12
+
13
+ if (isCloudfrontUrl(url)) {
14
+ return url
15
+ }
16
+
17
+ const ftcmsMatch = executeFtcmsMatch(url)
18
+
19
+ if (ftcmsMatch) {
20
+ return `ftcms:${ftcmsMatch.pop()}`
21
+ }
22
+
23
+ const ftheadSchemeMatch = executeFtheadMatch(url)
24
+
25
+ if (ftheadSchemeMatch) {
26
+ return ftheadSchemeMatch.pop()
27
+ }
28
+ }
29
+
30
+ function isCloudfrontUrl(url: string) {
31
+ const AWS_CLOUDFRONT_HOSTNAME = 'd1e00ek4ebabms.cloudfront.net'
32
+
33
+ try {
34
+ const urlHostname = new URL(url).hostname
35
+ return urlHostname && urlHostname === AWS_CLOUDFRONT_HOSTNAME
36
+ } catch {
37
+ return false
38
+ }
39
+ }
40
+
41
+ function executeFtcmsMatch(url: string) {
42
+ return UUID_REGEX.exec(url)
43
+ }
44
+
45
+ function executeFtheadMatch(url: string) {
46
+ return IMAGESET_REGEX.exec(url)
47
+ }
@@ -88,11 +88,9 @@ describe('Concept model', () => {
88
88
  })
89
89
 
90
90
  describe('headshot()', () => {
91
- it('throws an error if the concept is not for an author', async () => {
91
+ it('returns null if the concept is not for an author', async () => {
92
92
  const model = new Concept(topic, context)
93
- await expect(model.headshot()).rejects.toThrowError(
94
- 'Cannot request headshot for a non-Author concept'
95
- )
93
+ expect(await model.headshot()).toEqual(null)
96
94
  })
97
95
 
98
96
  it('returns the headshot for an author', async () => {
@@ -5,6 +5,7 @@ import imageServiceUrl from '../helpers/imageService'
5
5
  import isError from '../helpers/isError'
6
6
  import { logRecoverableError } from '@dotcom-reliability-kit/log-error'
7
7
  import conceptIds from '@financial-times/n-concept-ids'
8
+ import decorateHeadshotUrl, { UUID_REGEX } from '../helpers/decorateHeadshotUrl'
8
9
 
9
10
  const CAPI_ID_PREFIX = /^https?:\/\/(?:www|api)\.ft\.com\/things?\//
10
11
  const BASE_URL = 'https://www.ft.com/stream/'
@@ -165,26 +166,30 @@ export class Concept {
165
166
  }
166
167
 
167
168
  async headshot(args?: HeadshotArguments) {
168
- if (!this.isAuthor()) {
169
- throw new Error('Cannot request headshot for a non-Author concept')
170
- }
169
+ const uuid = this.apiUrl().match(UUID_REGEX)?.[0]
170
+
171
+ if (!this.isAuthor() || !uuid) return null
171
172
 
172
173
  try {
173
- const peopleData = await this.context.dataSources.capi.getPerson(
174
- this.uuid()
175
- )
176
-
177
- if (!peopleData || !peopleData._imageUrl) return null
178
-
179
- return imageServiceUrl({
180
- url: peopleData._imageUrl,
181
- systemCode: this.#systemCode,
182
- width: args?.width || 150,
183
- dpr: args?.dpr ?? undefined,
184
- })
185
- } catch (err) {
174
+ const person = await this.context.dataSources.capi.getPerson(uuid)
175
+ const url = decorateHeadshotUrl(person)
176
+
177
+ return url
178
+ ? imageServiceUrl({
179
+ url,
180
+ systemCode: this.#systemCode,
181
+ width: args?.width || 150,
182
+ dpr: args?.dpr ?? undefined,
183
+ })
184
+ : null
185
+ } catch (error) {
186
+ if (isError(error)) {
187
+ logRecoverableError({
188
+ logger: this.context.logger,
189
+ error,
190
+ })
191
+ }
186
192
  return null
187
- //TODO: log operational error here
188
193
  }
189
194
  }
190
195
  }
@@ -30,6 +30,9 @@ export class Picture {
30
30
  const images = this.images()
31
31
  const standard =
32
32
  images.find((image) => image.format() === 'standard-inline') ?? images[0]
33
+
34
+ // this assert will never fail, as a Picture model won't be constructed if
35
+ // the imageset is empty. it's here for Typescript's benefit
33
36
  assertDefined(
34
37
  standard,
35
38
  'Picture must contain at least one image to display'
@@ -132,10 +132,6 @@ export class Topper {
132
132
  }
133
133
 
134
134
  backgroundColour(): TopperBackgroundColourValues {
135
- if (this.capiResponse.isAlphaville()) {
136
- return 'matisse'
137
- }
138
-
139
135
  if (
140
136
  this.capiResponse.type() === 'ContentPackage' &&
141
137
  this.capiResponse.design()
@@ -158,6 +154,10 @@ export class Topper {
158
154
  return this.capiResponse.isContainedInPackage() ? 'wheat' : 'sky'
159
155
  }
160
156
 
157
+ if (this.capiResponse.isAlphaville()) {
158
+ return 'matisse'
159
+ }
160
+
161
161
  if (type === 'PodcastTopper') {
162
162
  return 'slate'
163
163
  }
@@ -254,11 +254,11 @@ export class Topper {
254
254
  }
255
255
 
256
256
  columnist() {
257
- if (this.capiResponse.isColumn()) {
258
- return this.capiResponse.getAuthors()[0] ?? null
259
- }
257
+ const authors = this.capiResponse.getAuthors()
258
+ const isOpinionOrColumn =
259
+ this.type() === 'OpinionTopper' || this.capiResponse.isColumn()
260
260
 
261
- return null
261
+ return isOpinionOrColumn && authors.length ? authors[0] : null
262
262
  }
263
263
 
264
264
  brandConcept() {
@@ -279,14 +279,28 @@ export class Topper {
279
279
  return this.capiResponse.design()
280
280
  }
281
281
 
282
- headshot(args: HeadshotArguments) {
283
- const headshot = this.capiResponse.mainImage()
284
- if (!headshot) {
282
+ async headshot(args: HeadshotArguments) {
283
+ let headshotUrl: string | undefined
284
+
285
+ if (this.capiResponse.isPodcast()) {
286
+ headshotUrl = this.capiResponse.mainImage()?.url()
287
+ }
288
+
289
+ if (this.capiResponse.isOpinion()) {
290
+ const authors = this.capiResponse.getAuthors()
291
+ const headshotUrls: (string | null)[] = await Promise.all(
292
+ authors.map(async (author) => await author.headshot(args))
293
+ ).then((res) => res.filter(Boolean))
294
+
295
+ headshotUrl = headshotUrls[0] ?? undefined
296
+ }
297
+
298
+ if (!headshotUrl) {
285
299
  return null
286
300
  }
287
301
 
288
302
  return imageServiceUrl({
289
- url: headshot.url(),
303
+ url: headshotUrl,
290
304
  systemCode: this.#systemCode,
291
305
  width: args.width ?? undefined,
292
306
  dpr: args.dpr ?? undefined,
@@ -7,6 +7,7 @@ const Concept = z.object({
7
7
  prefLabel: z.string(),
8
8
  type: z.string().optional(),
9
9
  types: z.string().array(),
10
+ headshot: z.string().optional(),
10
11
  })
11
12
 
12
13
  export const Annotation = Concept.extend({
@@ -14,7 +14,7 @@ export const ImageSet = {
14
14
  uuidFromUrl(embed.id) === uuidFromUrl(parent.reference.id)
15
15
  )
16
16
 
17
- return imageSet && imageSet.members
17
+ return imageSet && imageSet.members && imageSet.members.length > 0
18
18
  ? new Picture(imageSet, isLiveBlog, context)
19
19
  : null
20
20
  },
@@ -306,7 +306,7 @@ const commonTagMappings: TagMappings = {
306
306
  '.n-content-layout img': ($el) => ({
307
307
  type: 'layout-image',
308
308
  id: $el.attr('src') || '',
309
- caption: $el.attr('longDesc') || '',
309
+ caption: $el.attr('longdesc') || '',
310
310
  alt: $el.attr('alt') || '',
311
311
  credit: $el.attr('data-copyright') || '',
312
312
  }),
@@ -479,7 +479,7 @@ const commonTagMappings: TagMappings = {
479
479
  // HACK needs to come after LayoutImage. TODO sort by selector specificity
480
480
  img: ($el) => ({
481
481
  type: 'raw-image',
482
- caption: $el.attr('longDesc') || '',
482
+ caption: $el.attr('longdesc') || '',
483
483
  credit: $el.attr('data-copyright') || '',
484
484
  alt: $el.attr('alt') || '',
485
485
  url: $el.attr('src') || '',
@@ -16,6 +16,7 @@ const resolvers = {
16
16
  backgroundColour: (topper) => topper.backgroundColour(),
17
17
  displayConcept: (topper) => topper.displayConcept(),
18
18
  followButtonVariant: (topper) => topper.followButtonVariant(),
19
+ genreConcept: (topper) => topper.genreConcept(),
19
20
  },
20
21
 
21
22
  TopperWithImages: {
@@ -38,9 +39,7 @@ const resolvers = {
38
39
  },
39
40
 
40
41
  OpinionTopper: {
41
- async headshot(topper, args) {
42
- return topper.columnist()?.headshot(args) || null
43
- },
42
+ headshot: (topper, args) => topper.headshot(args),
44
43
  columnist: (topper) => topper.columnist(),
45
44
  },
46
45