@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.
- package/CHANGELOG.md +16 -0
- package/lib/generated/index.d.ts +33 -4
- package/lib/helpers/flatten-formatted-zod-errors.d.ts +1 -1
- package/lib/helpers/flatten-formatted-zod-errors.js +1 -0
- package/lib/helpers/flatten-formatted-zod-errors.js.map +1 -1
- package/lib/model/CapiResponse.d.ts +3 -2
- package/lib/model/CapiResponse.js +84 -23
- package/lib/model/CapiResponse.js.map +1 -1
- package/lib/model/Person.d.ts +2 -0
- package/lib/model/Person.js +16 -0
- package/lib/model/Person.js.map +1 -1
- package/lib/model/Topper.js +19 -1
- package/lib/model/Topper.js.map +1 -1
- package/lib/model/schemas/capi/article.d.ts +222 -0
- package/lib/model/schemas/capi/audio.d.ts +194 -0
- package/lib/model/schemas/capi/base-schema.d.ts +420 -0
- package/lib/model/schemas/capi/base-schema.js +25 -2
- package/lib/model/schemas/capi/base-schema.js.map +1 -1
- package/lib/model/schemas/capi/content-package.d.ts +194 -0
- package/lib/model/schemas/capi/custom-code-component.d.ts +1906 -0
- package/lib/model/schemas/capi/custom-code-component.js +17 -0
- package/lib/model/schemas/capi/custom-code-component.js.map +1 -0
- package/lib/model/schemas/capi/index.d.ts +2947 -16
- package/lib/model/schemas/capi/index.js +3 -0
- package/lib/model/schemas/capi/index.js.map +1 -1
- package/lib/model/schemas/capi/internal-content.d.ts +5 -2
- package/lib/model/schemas/capi/live-blog-package.d.ts +222 -0
- package/lib/model/schemas/capi/placeholder.d.ts +222 -0
- package/lib/model/schemas/capi/video.d.ts +194 -0
- package/lib/resolvers/content-tree/Workarounds.d.ts +5 -1
- package/lib/resolvers/content-tree/references/CustomCodeComponent.d.ts +13 -0
- package/lib/resolvers/content-tree/references/CustomCodeComponent.js +58 -0
- package/lib/resolvers/content-tree/references/CustomCodeComponent.js.map +1 -0
- package/lib/resolvers/content-tree/references/Reference.d.ts +1 -1
- package/lib/resolvers/content-tree/references/index.d.ts +3 -1
- package/lib/resolvers/content-tree/references/index.js +3 -0
- package/lib/resolvers/content-tree/references/index.js.map +1 -1
- package/lib/resolvers/content-tree/tagMappings.js +27 -2
- package/lib/resolvers/content-tree/tagMappings.js.map +1 -1
- package/lib/resolvers/content-tree/tagMappings.test.js +19 -0
- package/lib/resolvers/content-tree/tagMappings.test.js.map +1 -1
- package/lib/resolvers/content.d.ts +8 -8
- package/lib/resolvers/content.js +4 -0
- package/lib/resolvers/content.js.map +1 -1
- package/lib/resolvers/index.d.ts +10 -9
- package/lib/resolvers/scalars.d.ts +2 -2
- package/lib/resolvers/scalars.js +1 -0
- package/lib/resolvers/scalars.js.map +1 -1
- package/package.json +2 -2
- package/queries/article.graphql +24 -7
- package/src/generated/index.ts +34 -3
- package/src/helpers/flatten-formatted-zod-errors.ts +2 -1
- package/src/model/CapiResponse.ts +93 -34
- package/src/model/Person.ts +19 -0
- package/src/model/Topper.ts +18 -1
- package/src/model/schemas/capi/base-schema.ts +27 -1
- package/src/model/schemas/capi/custom-code-component.ts +20 -0
- package/src/model/schemas/capi/index.ts +3 -0
- package/src/model/schemas/capi/internal-content.ts +7 -0
- package/src/resolvers/content-tree/Workarounds.ts +7 -1
- package/src/resolvers/content-tree/references/CustomCodeComponent.ts +78 -0
- package/src/resolvers/content-tree/references/index.ts +5 -0
- package/src/resolvers/content-tree/tagMappings.test.ts +29 -0
- package/src/resolvers/content-tree/tagMappings.ts +46 -4
- package/src/resolvers/content.ts +7 -0
- package/src/resolvers/scalars.ts +3 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/typedefs/content.graphql +1 -1
- 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 (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
267
|
-
|
|
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
|
-
|
|
311
|
+
if ('accessLevel' in this.capiData) {
|
|
312
|
+
return this.capiData.accessLevel ?? 'subscribed'
|
|
313
|
+
}
|
|
314
|
+
return 'subscribed'
|
|
287
315
|
}
|
|
288
316
|
|
|
289
317
|
editorialDesk() {
|
|
290
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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
|
-
|
|
521
|
-
'alternativeImages' in this.capiData
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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
|
-
|
|
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
|
-
|
|
844
|
+
if ('editorialDesk' in this.capiData) {
|
|
845
|
+
return this.capiData.editorialDesk === '/FT/Commercial/PartnerContent'
|
|
846
|
+
}
|
|
847
|
+
return false
|
|
789
848
|
}
|
|
790
849
|
}
|
package/src/model/Person.ts
CHANGED
|
@@ -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
|
}
|
package/src/model/Topper.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
273
|
-
|
|
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)
|