@financial-times/cp-content-pipeline-schema 3.0.4 → 3.1.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 (68) hide show
  1. package/CHANGELOG.md +14 -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 +64 -22
  8. package/lib/model/CapiResponse.js.map +1 -1
  9. package/lib/model/Concept.js +5 -1
  10. package/lib/model/Concept.js.map +1 -1
  11. package/lib/model/Concept.test.js +5 -0
  12. package/lib/model/Concept.test.js.map +1 -1
  13. package/lib/model/schemas/capi/article.d.ts +222 -0
  14. package/lib/model/schemas/capi/audio.d.ts +194 -0
  15. package/lib/model/schemas/capi/base-schema.d.ts +420 -0
  16. package/lib/model/schemas/capi/base-schema.js +25 -2
  17. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  18. package/lib/model/schemas/capi/content-package.d.ts +194 -0
  19. package/lib/model/schemas/capi/custom-code-component.d.ts +1906 -0
  20. package/lib/model/schemas/capi/custom-code-component.js +17 -0
  21. package/lib/model/schemas/capi/custom-code-component.js.map +1 -0
  22. package/lib/model/schemas/capi/index.d.ts +2947 -16
  23. package/lib/model/schemas/capi/index.js +3 -0
  24. package/lib/model/schemas/capi/index.js.map +1 -1
  25. package/lib/model/schemas/capi/internal-content.d.ts +5 -2
  26. package/lib/model/schemas/capi/live-blog-package.d.ts +222 -0
  27. package/lib/model/schemas/capi/placeholder.d.ts +222 -0
  28. package/lib/model/schemas/capi/video.d.ts +194 -0
  29. package/lib/resolvers/content-tree/Workarounds.d.ts +5 -1
  30. package/lib/resolvers/content-tree/references/CustomCodeComponent.d.ts +13 -0
  31. package/lib/resolvers/content-tree/references/CustomCodeComponent.js +58 -0
  32. package/lib/resolvers/content-tree/references/CustomCodeComponent.js.map +1 -0
  33. package/lib/resolvers/content-tree/references/Reference.d.ts +1 -1
  34. package/lib/resolvers/content-tree/references/index.d.ts +3 -1
  35. package/lib/resolvers/content-tree/references/index.js +3 -0
  36. package/lib/resolvers/content-tree/references/index.js.map +1 -1
  37. package/lib/resolvers/content-tree/tagMappings.js +27 -2
  38. package/lib/resolvers/content-tree/tagMappings.js.map +1 -1
  39. package/lib/resolvers/content-tree/tagMappings.test.js +19 -0
  40. package/lib/resolvers/content-tree/tagMappings.test.js.map +1 -1
  41. package/lib/resolvers/content.d.ts +8 -8
  42. package/lib/resolvers/content.js +4 -0
  43. package/lib/resolvers/content.js.map +1 -1
  44. package/lib/resolvers/index.d.ts +10 -9
  45. package/lib/resolvers/scalars.d.ts +2 -2
  46. package/lib/resolvers/scalars.js +1 -0
  47. package/lib/resolvers/scalars.js.map +1 -1
  48. package/package.json +2 -2
  49. package/queries/article.graphql +24 -0
  50. package/src/generated/index.ts +34 -3
  51. package/src/helpers/flatten-formatted-zod-errors.ts +2 -1
  52. package/src/model/CapiResponse.ts +74 -29
  53. package/src/model/Concept.test.ts +8 -0
  54. package/src/model/Concept.ts +7 -1
  55. package/src/model/schemas/capi/base-schema.ts +27 -1
  56. package/src/model/schemas/capi/custom-code-component.ts +20 -0
  57. package/src/model/schemas/capi/index.ts +3 -0
  58. package/src/model/schemas/capi/internal-content.ts +7 -0
  59. package/src/resolvers/content-tree/Workarounds.ts +7 -1
  60. package/src/resolvers/content-tree/references/CustomCodeComponent.ts +78 -0
  61. package/src/resolvers/content-tree/references/index.ts +5 -0
  62. package/src/resolvers/content-tree/tagMappings.test.ts +29 -0
  63. package/src/resolvers/content-tree/tagMappings.ts +46 -4
  64. package/src/resolvers/content.ts +7 -0
  65. package/src/resolvers/scalars.ts +3 -1
  66. package/tsconfig.tsbuildinfo +1 -1
  67. package/typedefs/content.graphql +1 -1
  68. 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
 
@@ -297,11 +308,17 @@ export class CapiResponse {
297
308
  }
298
309
 
299
310
  accessLevel(): LiteralUnionScalarValues<typeof AccessLevel> {
300
- return this.capiData.accessLevel ?? 'subscribed'
311
+ if ('accessLevel' in this.capiData) {
312
+ return this.capiData.accessLevel ?? 'subscribed'
313
+ }
314
+ return 'subscribed'
301
315
  }
302
316
 
303
317
  editorialDesk() {
304
- return this.capiData.editorialDesk ?? null
318
+ if ('editorialDesk' in this.capiData) {
319
+ return this.capiData.editorialDesk ?? null
320
+ }
321
+ return null
305
322
  }
306
323
 
307
324
  canBeSyndicated(): LiteralUnionScalarValues<typeof CanBeSyndicated> {
@@ -337,6 +354,9 @@ export class CapiResponse {
337
354
  }
338
355
 
339
356
  title() {
357
+ if (!('title' in this.capiData)) {
358
+ return ''
359
+ }
340
360
  // CI-2038 HACK to remove "Comment:" prefix from live blog post titles
341
361
  // as this is as the sole indicator that this is an opinion post
342
362
  // while we're waiting for the annotation data to be in CAPI
@@ -379,9 +399,11 @@ export class CapiResponse {
379
399
  }
380
400
 
381
401
  modifiedTimestamp() {
382
- return this.capiData.lastModified
383
- ? new Date(this.capiData.lastModified).getTime()
384
- : null
402
+ if ('lastModified' in this.capiData && this.capiData.lastModified) {
403
+ return new Date(this.capiData.lastModified).getTime()
404
+ } else {
405
+ return null
406
+ }
385
407
  }
386
408
 
387
409
  publishReference() {
@@ -389,7 +411,10 @@ export class CapiResponse {
389
411
  }
390
412
 
391
413
  alternativeTitle() {
392
- return this.capiData.alternativeTitles ?? null
414
+ if ('alternativeTitles' in this.capiData) {
415
+ return this.capiData.alternativeTitles ?? null
416
+ }
417
+ return null
393
418
  }
394
419
 
395
420
  alternativeStandfirst() {
@@ -401,9 +426,12 @@ export class CapiResponse {
401
426
  }
402
427
 
403
428
  overrideTitle(title: string) {
404
- const clone = cloneDeep(this.capiData)
405
- clone.title = title
406
- 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
407
435
  }
408
436
 
409
437
  async containedIn() {
@@ -526,17 +554,27 @@ export class CapiResponse {
526
554
  }
527
555
 
528
556
  mainImage() {
529
- return this.#createCAPIImage(this.capiData.mainImage)
557
+ if ('mainImage' in this.capiData) {
558
+ return this.#createCAPIImage(this.capiData.mainImage)
559
+ }
560
+ return null
530
561
  }
531
562
 
532
563
  teaserImage() {
533
564
  // Some content published via methode uses promotionalImage for teasers
534
- const alternativeImage =
535
- 'alternativeImages' in this.capiData
536
- ? this.capiData.alternativeImages?.promotionalImage
537
- : undefined
538
- const image = alternativeImage ?? this.capiData.mainImage
539
- 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
540
578
  }
541
579
 
542
580
  #createCAPIImage(image: MainImage | undefined) {
@@ -604,6 +642,7 @@ export class CapiResponse {
604
642
  // while we're waiting for the annotation data to be in CAPI
605
643
  if (
606
644
  this.type() === 'LiveBlogPost' &&
645
+ 'title' in this.capiData &&
607
646
  this.capiData.title.startsWith('Comment:')
608
647
  ) {
609
648
  return true
@@ -618,7 +657,10 @@ export class CapiResponse {
618
657
  }
619
658
 
620
659
  isCentralBanking() {
621
- 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
622
664
  }
623
665
 
624
666
  isPlaceholder() {
@@ -799,6 +841,9 @@ export class CapiResponse {
799
841
  }
800
842
 
801
843
  isPartnerContent(): boolean {
802
- 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
803
848
  }
804
849
  }
@@ -73,5 +73,13 @@ describe('Concept model', () => {
73
73
  '/vanity-url'
74
74
  )
75
75
  })
76
+
77
+ it('returns the full vanity URL when vanity=true and relative=true but vanity URL host is not www.ft.com', async () => {
78
+ const model = new Concept(topic, context)
79
+ vanityMock.mockResolvedValue('https://professional-monetary-policy-radar.ft.com')
80
+ expect(await model.url({ vanity: true, relative: true })).toEqual(
81
+ 'https://professional-monetary-policy-radar.ft.com'
82
+ )
83
+ })
76
84
  })
77
85
  })
@@ -162,7 +162,13 @@ export class Concept {
162
162
  }
163
163
 
164
164
  if (args?.relative) {
165
- return new URL(url).pathname
165
+ const urlObject = new URL(url)
166
+
167
+ if (urlObject.host === 'www.ft.com') {
168
+ return urlObject.pathname
169
+ }
170
+
171
+ return url
166
172
  }
167
173
 
168
174
  return url
@@ -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)
@@ -15,6 +15,7 @@ import {
15
15
  IndicatorsResolvers,
16
16
  MediaResolvers,
17
17
  } from '../generated'
18
+ import { BaseError } from '@dotcom-reliability-kit/errors'
18
19
 
19
20
  const contentResolvers: ContentResolvers = {
20
21
  accessLevel: (parent) => parent.accessLevel(),
@@ -58,6 +59,12 @@ const resolvers = {
58
59
  return 'Video'
59
60
  }
60
61
 
62
+ if (type === 'CustomCodeComponent') {
63
+ throw new BaseError(
64
+ 'CustomCodeComponent is not supported as a root content type'
65
+ )
66
+ }
67
+
61
68
  return type
62
69
  },
63
70
  ...contentResolvers,
@@ -128,7 +128,8 @@ export const ContentType = new LiteralUnionScalar<
128
128
  'LiveBlogPost',
129
129
  'ContentPackage',
130
130
  'Content',
131
- 'MediaResource'
131
+ 'MediaResource',
132
+ 'CustomCodeComponent'
132
133
  ]
133
134
  >({
134
135
  name: 'ContentType',
@@ -142,6 +143,7 @@ export const ContentType = new LiteralUnionScalar<
142
143
  'ContentPackage',
143
144
  'Content',
144
145
  'MediaResource',
146
+ 'CustomCodeComponent',
145
147
  ],
146
148
  })
147
149