@financial-times/cp-content-pipeline-schema 3.0.3 → 3.1.0

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 (69) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/lib/generated/index.d.ts +33 -4
  3. package/lib/helpers/flatten-formatted-zod-errors.d.ts +1 -1
  4. package/lib/helpers/flatten-formatted-zod-errors.js +1 -0
  5. package/lib/helpers/flatten-formatted-zod-errors.js.map +1 -1
  6. package/lib/model/CapiResponse.d.ts +3 -2
  7. package/lib/model/CapiResponse.js +84 -23
  8. package/lib/model/CapiResponse.js.map +1 -1
  9. package/lib/model/Person.d.ts +2 -0
  10. package/lib/model/Person.js +16 -0
  11. package/lib/model/Person.js.map +1 -1
  12. package/lib/model/Topper.js +19 -1
  13. package/lib/model/Topper.js.map +1 -1
  14. package/lib/model/schemas/capi/article.d.ts +222 -0
  15. package/lib/model/schemas/capi/audio.d.ts +194 -0
  16. package/lib/model/schemas/capi/base-schema.d.ts +420 -0
  17. package/lib/model/schemas/capi/base-schema.js +25 -2
  18. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  19. package/lib/model/schemas/capi/content-package.d.ts +194 -0
  20. package/lib/model/schemas/capi/custom-code-component.d.ts +1906 -0
  21. package/lib/model/schemas/capi/custom-code-component.js +17 -0
  22. package/lib/model/schemas/capi/custom-code-component.js.map +1 -0
  23. package/lib/model/schemas/capi/index.d.ts +2947 -16
  24. package/lib/model/schemas/capi/index.js +3 -0
  25. package/lib/model/schemas/capi/index.js.map +1 -1
  26. package/lib/model/schemas/capi/internal-content.d.ts +5 -2
  27. package/lib/model/schemas/capi/live-blog-package.d.ts +222 -0
  28. package/lib/model/schemas/capi/placeholder.d.ts +222 -0
  29. package/lib/model/schemas/capi/video.d.ts +194 -0
  30. package/lib/resolvers/content-tree/Workarounds.d.ts +5 -1
  31. package/lib/resolvers/content-tree/references/CustomCodeComponent.d.ts +13 -0
  32. package/lib/resolvers/content-tree/references/CustomCodeComponent.js +58 -0
  33. package/lib/resolvers/content-tree/references/CustomCodeComponent.js.map +1 -0
  34. package/lib/resolvers/content-tree/references/Reference.d.ts +1 -1
  35. package/lib/resolvers/content-tree/references/index.d.ts +3 -1
  36. package/lib/resolvers/content-tree/references/index.js +3 -0
  37. package/lib/resolvers/content-tree/references/index.js.map +1 -1
  38. package/lib/resolvers/content-tree/tagMappings.js +27 -2
  39. package/lib/resolvers/content-tree/tagMappings.js.map +1 -1
  40. package/lib/resolvers/content-tree/tagMappings.test.js +19 -0
  41. package/lib/resolvers/content-tree/tagMappings.test.js.map +1 -1
  42. package/lib/resolvers/content.d.ts +8 -8
  43. package/lib/resolvers/content.js +4 -0
  44. package/lib/resolvers/content.js.map +1 -1
  45. package/lib/resolvers/index.d.ts +10 -9
  46. package/lib/resolvers/scalars.d.ts +2 -2
  47. package/lib/resolvers/scalars.js +1 -0
  48. package/lib/resolvers/scalars.js.map +1 -1
  49. package/package.json +2 -2
  50. package/queries/article.graphql +24 -7
  51. package/src/generated/index.ts +34 -3
  52. package/src/helpers/flatten-formatted-zod-errors.ts +2 -1
  53. package/src/model/CapiResponse.ts +93 -34
  54. package/src/model/Person.ts +19 -0
  55. package/src/model/Topper.ts +18 -1
  56. package/src/model/schemas/capi/base-schema.ts +27 -1
  57. package/src/model/schemas/capi/custom-code-component.ts +20 -0
  58. package/src/model/schemas/capi/index.ts +3 -0
  59. package/src/model/schemas/capi/internal-content.ts +7 -0
  60. package/src/resolvers/content-tree/Workarounds.ts +7 -1
  61. package/src/resolvers/content-tree/references/CustomCodeComponent.ts +78 -0
  62. package/src/resolvers/content-tree/references/index.ts +5 -0
  63. package/src/resolvers/content-tree/tagMappings.test.ts +29 -0
  64. package/src/resolvers/content-tree/tagMappings.ts +46 -4
  65. package/src/resolvers/content.ts +7 -0
  66. package/src/resolvers/scalars.ts +3 -1
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/typedefs/content.graphql +1 -1
  69. package/typedefs/references/CustomCodeComponent.graphql +17 -0
@@ -3,6 +3,7 @@ import type {
3
3
  ContentTypeSchemas,
4
4
  MainImage,
5
5
  ClipSet,
6
+ CustomCodeComponentReference,
6
7
  } from './schemas/capi/internal-content'
7
8
  import conceptIds from '@financial-times/n-concept-ids'
8
9
  import metadata from '@financial-times/n-display-metadata'
@@ -177,7 +178,7 @@ export class CapiResponse {
177
178
  return null
178
179
  }
179
180
 
180
- embeds(): (ImageSet | ClipSet)[] {
181
+ embeds(): (ImageSet | ClipSet | CustomCodeComponentReference)[] {
181
182
  if ('embeds' in this.capiData && this.capiData.embeds)
182
183
  return this.capiData.embeds
183
184
  return []
@@ -189,16 +190,19 @@ export class CapiResponse {
189
190
  }
190
191
 
191
192
  async byline({ vanity }: Partial<ContentUrlArgs>) {
192
- if (!this.capiData.byline) return null
193
-
194
- const byline = new Byline(
195
- this.capiData.byline,
196
- Boolean(vanity),
197
- this.annotations({ byPredicate: predicates.hasAuthor }),
198
- this
199
- )
193
+ if ('byline' in this.capiData) {
194
+ if (!this.capiData.byline) return null
195
+
196
+ const byline = new Byline(
197
+ this.capiData.byline,
198
+ Boolean(vanity),
199
+ this.annotations({ byPredicate: predicates.hasAuthor }),
200
+ this
201
+ )
200
202
 
201
- return byline.buildBylineTree()
203
+ return byline.buildBylineTree()
204
+ }
205
+ return null
202
206
  }
203
207
 
204
208
  #rawAnnotations() {
@@ -225,9 +229,16 @@ export class CapiResponse {
225
229
  return relative ? this.relativeUrl(vanity) : this.absoluteUrl(vanity)
226
230
  }
227
231
 
232
+ webUrl() {
233
+ if ('webUrl' in this.capiData) {
234
+ return this.capiData.webUrl
235
+ }
236
+ return null
237
+ }
238
+
228
239
  async absoluteUrl(vanity?: boolean | null) {
229
240
  const url =
230
- this.capiData.webUrl ??
241
+ this.webUrl() ??
231
242
  this.capiData.canonicalWebUrl ??
232
243
  `https://www.ft.com/content/${this.id()}`
233
244
 
@@ -263,11 +274,25 @@ export class CapiResponse {
263
274
  async authors(): Promise<Person[]> {
264
275
  const authors = this.annotations({ byPredicate: predicates.hasAuthor })
265
276
 
266
- return Promise.all(
267
- authors.map((author) =>
268
- this.context.dataSources.capi.getPerson(author.uuid())
269
- )
270
- )
277
+ const getPerson = async (author: Concept) => {
278
+ try {
279
+ return await this.context.dataSources.capi.getPerson(author.uuid())
280
+ } catch (error) {
281
+ if (isError(error)) {
282
+ this.context.logger.warn({
283
+ event: 'RECOVERABLE_ERROR',
284
+ error: new OperationalError({
285
+ message: `Error fetching CAPI Person for author ${author.prefLabel()}`,
286
+ code: 'AUTHOR_PERSON_FETCH_ERROR',
287
+ cause: error,
288
+ }),
289
+ })
290
+ }
291
+ return Person.fromConcept(author, this.context)
292
+ }
293
+ }
294
+
295
+ return Promise.all(authors.map(getPerson))
271
296
  }
272
297
 
273
298
  async primaryAuthor(): Promise<Person | null> {
@@ -283,11 +308,17 @@ export class CapiResponse {
283
308
  }
284
309
 
285
310
  accessLevel(): LiteralUnionScalarValues<typeof AccessLevel> {
286
- return this.capiData.accessLevel ?? 'subscribed'
311
+ if ('accessLevel' in this.capiData) {
312
+ return this.capiData.accessLevel ?? 'subscribed'
313
+ }
314
+ return 'subscribed'
287
315
  }
288
316
 
289
317
  editorialDesk() {
290
- return this.capiData.editorialDesk ?? null
318
+ if ('editorialDesk' in this.capiData) {
319
+ return this.capiData.editorialDesk ?? null
320
+ }
321
+ return null
291
322
  }
292
323
 
293
324
  canBeSyndicated(): LiteralUnionScalarValues<typeof CanBeSyndicated> {
@@ -323,6 +354,9 @@ export class CapiResponse {
323
354
  }
324
355
 
325
356
  title() {
357
+ if (!('title' in this.capiData)) {
358
+ return ''
359
+ }
326
360
  // CI-2038 HACK to remove "Comment:" prefix from live blog post titles
327
361
  // as this is as the sole indicator that this is an opinion post
328
362
  // while we're waiting for the annotation data to be in CAPI
@@ -365,9 +399,11 @@ export class CapiResponse {
365
399
  }
366
400
 
367
401
  modifiedTimestamp() {
368
- return this.capiData.lastModified
369
- ? new Date(this.capiData.lastModified).getTime()
370
- : null
402
+ if ('lastModified' in this.capiData && this.capiData.lastModified) {
403
+ return new Date(this.capiData.lastModified).getTime()
404
+ } else {
405
+ return null
406
+ }
371
407
  }
372
408
 
373
409
  publishReference() {
@@ -375,7 +411,10 @@ export class CapiResponse {
375
411
  }
376
412
 
377
413
  alternativeTitle() {
378
- return this.capiData.alternativeTitles ?? null
414
+ if ('alternativeTitles' in this.capiData) {
415
+ return this.capiData.alternativeTitles ?? null
416
+ }
417
+ return null
379
418
  }
380
419
 
381
420
  alternativeStandfirst() {
@@ -387,9 +426,12 @@ export class CapiResponse {
387
426
  }
388
427
 
389
428
  overrideTitle(title: string) {
390
- const clone = cloneDeep(this.capiData)
391
- clone.title = title
392
- return new CapiResponse(clone, this.context, this.packageContainer)
429
+ if ('title' in this.capiData) {
430
+ const clone = cloneDeep(this.capiData)
431
+ clone.title = title
432
+ return new CapiResponse(clone, this.context, this.packageContainer)
433
+ }
434
+ return this
393
435
  }
394
436
 
395
437
  async containedIn() {
@@ -512,17 +554,27 @@ export class CapiResponse {
512
554
  }
513
555
 
514
556
  mainImage() {
515
- return this.#createCAPIImage(this.capiData.mainImage)
557
+ if ('mainImage' in this.capiData) {
558
+ return this.#createCAPIImage(this.capiData.mainImage)
559
+ }
560
+ return null
516
561
  }
517
562
 
518
563
  teaserImage() {
519
564
  // Some content published via methode uses promotionalImage for teasers
520
- const alternativeImage =
521
- 'alternativeImages' in this.capiData
522
- ? this.capiData.alternativeImages?.promotionalImage
523
- : undefined
524
- const image = alternativeImage ?? this.capiData.mainImage
525
- return this.#createCAPIImage(image)
565
+ if (
566
+ 'alternativeImages' in this.capiData &&
567
+ this.capiData.alternativeImages?.promotionalImage
568
+ ) {
569
+ return this.#createCAPIImage(
570
+ this.capiData.alternativeImages.promotionalImage
571
+ )
572
+ } else {
573
+ if ('mainImage' in this.capiData) {
574
+ return this.#createCAPIImage(this.capiData.mainImage)
575
+ }
576
+ }
577
+ return null
526
578
  }
527
579
 
528
580
  #createCAPIImage(image: MainImage | undefined) {
@@ -590,6 +642,7 @@ export class CapiResponse {
590
642
  // while we're waiting for the annotation data to be in CAPI
591
643
  if (
592
644
  this.type() === 'LiveBlogPost' &&
645
+ 'title' in this.capiData &&
593
646
  this.capiData.title.startsWith('Comment:')
594
647
  ) {
595
648
  return true
@@ -604,7 +657,10 @@ export class CapiResponse {
604
657
  }
605
658
 
606
659
  isCentralBanking() {
607
- return this.capiData.editorialDesk === '/FT/Professional/Central Banking'
660
+ if ('editorialDesk' in this.capiData) {
661
+ return this.capiData.editorialDesk === '/FT/Professional/Central Banking'
662
+ }
663
+ return false
608
664
  }
609
665
 
610
666
  isPlaceholder() {
@@ -785,6 +841,9 @@ export class CapiResponse {
785
841
  }
786
842
 
787
843
  isPartnerContent(): boolean {
788
- return this.capiData.editorialDesk === '/FT/Commercial/PartnerContent'
844
+ if ('editorialDesk' in this.capiData) {
845
+ return this.capiData.editorialDesk === '/FT/Commercial/PartnerContent'
846
+ }
847
+ return false
789
848
  }
790
849
  }
@@ -7,6 +7,7 @@ import { CapiPerson } from './schemas/capi/internal-content'
7
7
  import flattenFormattedZodIssues from '../helpers/flatten-formatted-zod-errors'
8
8
  import { PersonHeadshotArgs } from '../generated'
9
9
  import { uuidFromUrl } from '../helpers/metadata'
10
+ import { Concept } from './Concept'
10
11
 
11
12
  export class Person {
12
13
  #systemCode: string
@@ -39,6 +40,24 @@ export class Person {
39
40
  return new Person(data as CapiPerson, context)
40
41
  }
41
42
 
43
+ // The concepts returned in the annotations contain *most* of the fields
44
+ // returned by the Person API so we can use one to create a simple Person.
45
+ // Useful as a fallback if the Person API fails.
46
+ static fromConcept(concept: Concept, context: QueryContext): Person {
47
+ const personShim: CapiPerson = {
48
+ id: concept.id(),
49
+ apiUrl: concept.apiUrl(),
50
+ prefLabel: concept.prefLabel(),
51
+ types: concept.types(),
52
+ directType: concept.directType(),
53
+ // there's no labels equivalent in Concept but we don't use this field
54
+ // anyway
55
+ labels: [],
56
+ }
57
+
58
+ return new Person(personShim, context)
59
+ }
60
+
42
61
  constructor(private person: CapiPerson, private context: QueryContext) {
43
62
  this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
44
63
  }
@@ -11,6 +11,8 @@ import imageServiceUrl from '../helpers/imageService'
11
11
  import { LeadFlourish } from './LeadFlourish'
12
12
  import type { TopperWithHeadshotHeadshotArgs } from '../generated'
13
13
  import { predicates } from './Concept'
14
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
15
+ import isError from '../helpers/isError'
14
16
 
15
17
  type TopperType =
16
18
  | 'DeepPortraitTopper'
@@ -346,7 +348,22 @@ export class Topper {
346
348
  }
347
349
 
348
350
  if (this.capiResponse.isOpinion()) {
349
- return (await this.capiResponse.primaryAuthor())?.headshot(args) ?? null
351
+ try {
352
+ const primaryAuthor = await this.capiResponse.primaryAuthor()
353
+ return primaryAuthor?.headshot(args) ?? null
354
+ } catch (error) {
355
+ if (isError(error)) {
356
+ this.context.logger.warn({
357
+ event: 'RECOVERABLE_ERROR',
358
+ error: new OperationalError({
359
+ message: 'Error fetching CAPI Person for headshot URL for topper',
360
+ code: 'HEADSHOT_PERSON_FETCH_ERROR',
361
+ cause: error,
362
+ }),
363
+ })
364
+ }
365
+ return null
366
+ }
350
367
  }
351
368
 
352
369
  return null
@@ -183,6 +183,9 @@ export const baseMetadataSchema = z.object({
183
183
  annotations: Annotation.array(),
184
184
  webUrl: z.string().optional(),
185
185
  canonicalWebUrl: z.string().optional(),
186
+ apiUrl: z.string().optional(),
187
+ path: z.string(),
188
+ versionRange: z.string(),
186
189
  type: z.string().optional(),
187
190
  types: z.string().array(),
188
191
  standout: z
@@ -267,11 +270,34 @@ export const baseContentSchema = z.object({
267
270
  transcript: z.string().optional(),
268
271
  })
269
272
 
273
+ export const CustomCodeComponentReference = baseMetadataSchema
274
+ .pick({
275
+ id: true,
276
+ canonicalWebUrl: true,
277
+ publishedDate: true,
278
+ firstPublishedDate: true,
279
+ publishReference: true,
280
+ canBeSyndicated: true,
281
+ canBeDistributed: true,
282
+ })
283
+ .merge(baseContentSchema.pick({ bodyXML: true }))
284
+ .merge(
285
+ z.object({
286
+ apiUrl: z.string(),
287
+ type: z.literal('http://www.ft.com/ontology/content/CustomCodeComponent'),
288
+ path: z.string(),
289
+ versionRange: z.string(),
290
+ attributes: z.record(z.union([z.string(), z.boolean(), z.undefined()])),
291
+ })
292
+ )
293
+
270
294
  export const baseMediaSchema = z.object({
271
295
  mainImage: MainImage.optional(),
272
296
  leadImages: LeadImage.array().optional(),
273
297
  alternativeImages: AlternativeImage.optional(),
274
298
  leadFlourish: LeadFlourish.optional(),
275
- embeds: z.array(z.union([ImageSet, ClipSet])).optional(),
299
+ embeds: z
300
+ .array(z.union([ImageSet, ClipSet, CustomCodeComponentReference]))
301
+ .optional(),
276
302
  dataSource: DataSource.array().optional(),
277
303
  })
@@ -0,0 +1,20 @@
1
+ import {
2
+ baseMetadataSchema,
3
+ baseMediaSchema,
4
+ CustomCodeComponentReference,
5
+ } from './base-schema'
6
+
7
+ const customCodeComponentMetadataSchema = baseMetadataSchema.pick({
8
+ annotations: true,
9
+ types: true,
10
+ })
11
+
12
+ const customCodeComponentMediaSchema = baseMediaSchema.pick({
13
+ embeds: true,
14
+ })
15
+
16
+ export const customCodeComponentSchema = CustomCodeComponentReference.omit({
17
+ type: true,
18
+ })
19
+ .merge(customCodeComponentMetadataSchema)
20
+ .merge(customCodeComponentMediaSchema)
@@ -6,6 +6,7 @@ import { audioSchema } from './audio'
6
6
  import { videoSchema } from './video'
7
7
  import { LiteralUnionScalarValues } from '../../../resolvers/literal-union'
8
8
  import { ContentType } from '../../../resolvers/scalars'
9
+ import { customCodeComponentSchema } from './custom-code-component'
9
10
 
10
11
  export const schemas = (
11
12
  contentType: LiteralUnionScalarValues<typeof ContentType>
@@ -24,6 +25,8 @@ export const schemas = (
24
25
  case 'Video':
25
26
  case 'MediaResource':
26
27
  return videoSchema
28
+ case 'CustomCodeComponent':
29
+ return customCodeComponentSchema
27
30
  default:
28
31
  return articleSchema
29
32
  }
@@ -8,6 +8,7 @@ import type {
8
8
  ClipSet,
9
9
  MainImage,
10
10
  LeadImage,
11
+ CustomCodeComponentReference,
11
12
  } from './base-schema'
12
13
 
13
14
  import { articleSchema } from './article'
@@ -16,6 +17,7 @@ import { placeholderSchema } from './placeholder'
16
17
  import { liveBlogPackageSchema } from './live-blog-package'
17
18
  import { audioSchema } from './audio'
18
19
  import { videoSchema } from './video'
20
+ import { customCodeComponentSchema } from './custom-code-component'
19
21
 
20
22
  import { contentPackageSchema } from './content-package'
21
23
 
@@ -25,6 +27,7 @@ type LiveBlogPackage = z.infer<typeof liveBlogPackageSchema>
25
27
  type ContentPackage = z.infer<typeof contentPackageSchema>
26
28
  type Audio = z.infer<typeof audioSchema>
27
29
  type Video = z.infer<typeof videoSchema>
30
+ type CustomCodeComponent = z.infer<typeof customCodeComponentSchema>
28
31
 
29
32
  export type CapiPerson = z.infer<typeof CapiPerson>
30
33
 
@@ -35,6 +38,9 @@ export type ImageSet = z.infer<typeof ImageSet>
35
38
  export type MainImage = z.infer<typeof MainImage>
36
39
  export type Clip = z.infer<typeof Clip>
37
40
  export type ClipSet = z.infer<typeof ClipSet>
41
+ export type CustomCodeComponentReference = z.infer<
42
+ typeof CustomCodeComponentReference
43
+ >
38
44
 
39
45
  export type ContentTypeSchemas =
40
46
  | Article
@@ -43,3 +49,4 @@ export type ContentTypeSchemas =
43
49
  | ContentPackage
44
50
  | Audio
45
51
  | Video
52
+ | CustomCodeComponent
@@ -75,6 +75,11 @@ export interface Video extends ContentTree.Node {
75
75
  embedded: boolean
76
76
  }
77
77
 
78
+ export interface CccFallbackText extends ContentTree.Node {
79
+ type: 'ccc-fallback-text'
80
+ children: ContentTree.Paragraph[]
81
+ }
82
+
78
83
  export interface OldClip extends ContentTree.Node {
79
84
  type: 'clip'
80
85
  url: string
@@ -218,11 +223,12 @@ export type AnyNode =
218
223
  | ContentTree.Blockquote
219
224
  | Pullquote
220
225
  | ContentTree.ImageSet
221
- | ContentTree.CustomCodeComponent
222
226
  | ContentTree.Root
223
227
  | Body
224
228
  | ClipSet
225
229
  | OldClip
230
+ | ContentTree.CustomCodeComponent
231
+ | CccFallbackText
226
232
  | Recommended
227
233
  | ContentTree.Tweet
228
234
  | Flourish
@@ -0,0 +1,78 @@
1
+ import { uuidFromUrl } from '../../../helpers/metadata'
2
+ import { CustomCodeComponentResolvers } from '../../../generated'
3
+ import { ReferenceWithCAPIData } from '.'
4
+ import { ContentTree } from '@financial-times/content-tree'
5
+ import { RichText } from '../../../model/RichText'
6
+ import { CapiResponse } from '../../../model/CapiResponse'
7
+ import type { QueryContext } from '../../..'
8
+ import isError from '../../../helpers/isError'
9
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
10
+
11
+ async function getReferencesFromBody(
12
+ parent: ReferenceWithCAPIData<ContentTree.CustomCodeComponent>,
13
+ context: QueryContext
14
+ ) {
15
+ const customCodeComponentEmbedData = getCustomCodeComponentEmbedsData(parent)
16
+
17
+ if (!customCodeComponentEmbedData) {
18
+ return null
19
+ }
20
+
21
+ let customCodeComponentContentData: CapiResponse
22
+ try {
23
+ customCodeComponentContentData = await context.dataSources.capi.getContent(
24
+ uuidFromUrl(customCodeComponentEmbedData.apiUrl)
25
+ )
26
+ } catch (error) {
27
+ if (isError(error)) {
28
+ context.logger.warn({
29
+ event: 'RECOVERABLE_ERROR',
30
+ error: new OperationalError({
31
+ code: 'CUSTOM_CODE_COMPONENT_CONTENT_ERROR',
32
+ message: `Error fetching content for CustomCodeComponent`,
33
+ cccData: parent,
34
+ cause: error,
35
+ }),
36
+ })
37
+ }
38
+
39
+ return null
40
+ }
41
+ const bodyXML = customCodeComponentContentData?.bodyXML() ?? `<body></body>`
42
+ // The CAPI Data will be used by the RichText to create the references when appropriate.
43
+ return new RichText('bodyXML', bodyXML, customCodeComponentContentData)
44
+ }
45
+
46
+ function getCustomCodeComponentEmbedsData(
47
+ parent: ReferenceWithCAPIData<ContentTree.CustomCodeComponent>
48
+ ) {
49
+ const customCodeComponentEmbeds = parent.contentApiData
50
+ ?.embeds()
51
+ ?.filter(
52
+ (embedded) =>
53
+ embedded.type ===
54
+ 'http://www.ft.com/ontology/content/CustomCodeComponent'
55
+ )
56
+
57
+ const uuid = uuidFromUrl(parent.reference.id)
58
+
59
+ if (!uuid) {
60
+ return
61
+ }
62
+
63
+ return customCodeComponentEmbeds?.find(
64
+ (embed) => uuidFromUrl(embed.id) === uuid
65
+ )
66
+ }
67
+
68
+ export const CustomCodeComponent = {
69
+ id: (parent) => uuidFromUrl(parent.reference.id),
70
+ type: (parent) => parent.reference.type,
71
+ layoutWidth: (parent) => parent.reference.layoutWidth ?? 'in-line',
72
+ path: (parent) => getCustomCodeComponentEmbedsData(parent)?.path ?? '',
73
+ versionRange: (parent) =>
74
+ getCustomCodeComponentEmbedsData(parent)?.versionRange ?? 'latest',
75
+ attributes: (parent) =>
76
+ getCustomCodeComponentEmbedsData(parent)?.attributes ?? {},
77
+ body: async (parent, _, context) => getReferencesFromBody(parent, context),
78
+ } satisfies CustomCodeComponentResolvers
@@ -6,6 +6,7 @@ import { Reference } from './Reference'
6
6
  import { Tweet } from './Tweet'
7
7
  import { ImageSet } from './ImageSet'
8
8
  import { ClipSet, Accessibility, Caption } from './ClipSet'
9
+ import { CustomCodeComponent } from './CustomCodeComponent'
9
10
  import { Video } from './Video'
10
11
  import { Flourish, FlourishFallback } from './Flourish'
11
12
  import { Recommended } from './Recommended'
@@ -16,6 +17,7 @@ import {
16
17
  FlourishResolvers,
17
18
  ImageSetResolvers,
18
19
  ClipSetResolvers,
20
+ CustomCodeComponentResolvers,
19
21
  LayoutImageResolvers,
20
22
  RawImageResolvers,
21
23
  RecommendedResolvers,
@@ -41,6 +43,7 @@ export const resolvers: {
41
43
  Tweet: TweetResolvers
42
44
  ImageSet: ImageSetResolvers
43
45
  ClipSet: ClipSetResolvers
46
+ CustomCodeComponent: CustomCodeComponentResolvers
44
47
  VideoReference: VideoReferenceResolvers
45
48
  Flourish: FlourishResolvers
46
49
  Recommended: RecommendedResolvers
@@ -57,6 +60,7 @@ export const resolvers: {
57
60
  Tweet,
58
61
  ImageSet,
59
62
  ClipSet,
63
+ CustomCodeComponent,
60
64
  VideoReference: Video,
61
65
  Flourish,
62
66
  Recommended,
@@ -76,6 +80,7 @@ export const mapNodeToReference = {
76
80
  'image-set': 'ImageSet',
77
81
  'clip-set': 'ClipSet',
78
82
  clip: 'ClipSet',
83
+ 'custom-code-component': 'CustomCodeComponent',
79
84
  video: 'VideoReference',
80
85
  recommended: 'Recommended',
81
86
  'layout-image': 'LayoutImage',
@@ -1,5 +1,6 @@
1
1
  import { AnyNode, OldClip } from './Workarounds'
2
2
  import tagMappings, { getBooleanAttributeValue } from './tagMappings'
3
+ import { ContentTree } from '@financial-times/content-tree'
3
4
  import { load } from 'cheerio'
4
5
 
5
6
  function expectNotArray<T>(thing: T | T[]): asserts thing is T {
@@ -59,3 +60,31 @@ describe('tagMappings test', () => {
59
60
  expect(mapping.posterCredits).toBe('Reuters')
60
61
  })
61
62
  })
63
+
64
+ it('custom-code-component mapping test', () => {
65
+ const bodyXML = `
66
+ <body>
67
+ <ft-content
68
+ type=\"http://www.ft.com/ontology/content/CustomCodeComponent\"
69
+ url=\"http://api.ft.com/content/1111111-1111-1111-1111-111111111111\"
70
+ data-layout-width=\"in-line\"
71
+ embedded=\"true\"
72
+ >
73
+ </ft-content>
74
+ </body>
75
+ `
76
+
77
+ const selector =
78
+ 'ft-content[type="http://www.ft.com/ontology/content/CustomCodeComponent"]'
79
+ const $el = load(bodyXML)(selector)
80
+ const mapping = tagMappings[selector]!($el, () => [])
81
+
82
+ expectNodeType<ContentTree.CustomCodeComponent>(
83
+ mapping,
84
+ 'custom-code-component'
85
+ )
86
+ expect(mapping.id).toBe(
87
+ 'http://api.ft.com/content/1111111-1111-1111-1111-111111111111'
88
+ )
89
+ expect(mapping.layoutWidth).toBe('in-line')
90
+ })
@@ -88,7 +88,12 @@ const validScrollytellingOption = <
88
88
  const articleTagMappings = (capiData?: CapiResponse): TagMappings => ({
89
89
  'body > ft-content[type="http://www.ft.com/ontology/content/ImageSet"]:first-child':
90
90
  ($el) => ({
91
- type: capiData?.topperHasImage() ? 'image-set' : 'main-image',
91
+ // CustomCodeComponent uses an inner bodyXML parsed as a RichText
92
+ // The rule about main-image should not apply to CustomCodeComponent.
93
+ type:
94
+ capiData?.type() === 'CustomCodeComponent' || capiData?.topperHasImage()
95
+ ? 'image-set'
96
+ : 'main-image',
92
97
  id: $el.attr('url') || '',
93
98
  }),
94
99
  'body > ft-content[type="http://www.ft.com/ontology/content/ImageSet"]:not(:first-child),:not(body) > ft-content[type="http://www.ft.com/ontology/content/ImageSet"]':
@@ -174,7 +179,33 @@ const commonTagMappings: TagMappings = {
174
179
  type: 'tweet',
175
180
  id: $el.attr('href') || '',
176
181
  }),
177
-
182
+ '[data-asset-type="custom-code-component-fallback-text"]': (
183
+ $el,
184
+ traverse,
185
+ context
186
+ ) => ({
187
+ type: 'ccc-fallback-text',
188
+ children: everyChildIsType(
189
+ 'paragraph',
190
+ traverse(),
191
+ 'ccc-fallback-text',
192
+ context
193
+ ),
194
+ }),
195
+ 'ft-content[type="http://www.ft.com/ontology/content/CustomCodeComponent"]': (
196
+ $el
197
+ ) => {
198
+ const layoutWidth = $el.attr('data-layout-width')
199
+ return {
200
+ type: 'custom-code-component',
201
+ id: $el.attr('url') || '',
202
+ layoutWidth:
203
+ layoutWidth &&
204
+ ['in-line', 'mid-grid', 'full-grid', 'full-bleed'].includes(layoutWidth)
205
+ ? (layoutWidth as 'in-line' | 'mid-grid' | 'full-grid' | 'full-bleed')
206
+ : 'in-line',
207
+ }
208
+ },
178
209
  'h4,h5,h6': ($el, traverse, context) => ({
179
210
  type: 'heading',
180
211
  level: 'label',
@@ -269,8 +300,19 @@ const commonTagMappings: TagMappings = {
269
300
  //TODO: this is a bit gross??
270
301
  const isValidWidth = (
271
302
  str: string
272
- ): str is 'inset-left' | 'full-grid' | 'full-width' => {
273
- return ['inset-left', 'full-grid', 'full-width'].includes(str)
303
+ ): str is
304
+ | 'inset-left'
305
+ | 'full-grid'
306
+ | 'full-width'
307
+ | 'in-line'
308
+ | 'mid-grid' => {
309
+ return [
310
+ 'inset-left',
311
+ 'full-grid',
312
+ 'full-width',
313
+ 'in-line',
314
+ 'mid-grid',
315
+ ].includes(str)
274
316
  }
275
317
  const isValidName = (str: string): str is 'auto' | 'card' | 'timeline' => {
276
318
  return ['auto', 'card', 'timeline'].includes(str)