@financial-times/cp-content-pipeline-schema 1.2.4 → 1.3.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 (51) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/lib/generated/index.d.ts +14 -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/Workarounds.d.ts +5 -1
  24. package/lib/resolvers/content-tree/nodePredicates.d.ts +3 -3
  25. package/lib/resolvers/content-tree/references/ImageSet.js +1 -1
  26. package/lib/resolvers/content-tree/references/ImageSet.js.map +1 -1
  27. package/lib/resolvers/content-tree/tagMappings.js +16 -0
  28. package/lib/resolvers/content-tree/tagMappings.js.map +1 -1
  29. package/lib/resolvers/content.d.ts +3 -0
  30. package/lib/resolvers/content.js +3 -0
  31. package/lib/resolvers/content.js.map +1 -1
  32. package/lib/resolvers/index.d.ts +6 -2
  33. package/lib/resolvers/topper.d.ts +3 -2
  34. package/lib/resolvers/topper.js +2 -3
  35. package/lib/resolvers/topper.js.map +1 -1
  36. package/package.json +1 -1
  37. package/src/generated/index.ts +11 -3
  38. package/src/helpers/decorateHeadshotUrl.ts +47 -0
  39. package/src/model/Concept.test.ts +2 -4
  40. package/src/model/Concept.ts +22 -17
  41. package/src/model/Picture.ts +3 -0
  42. package/src/model/Topper.ts +26 -12
  43. package/src/model/schemas/capi/base-schema.ts +1 -0
  44. package/src/resolvers/content-tree/Workarounds.ts +6 -0
  45. package/src/resolvers/content-tree/references/ImageSet.ts +1 -1
  46. package/src/resolvers/content-tree/tagMappings.ts +18 -0
  47. package/src/resolvers/content.ts +5 -0
  48. package/src/resolvers/topper.ts +2 -3
  49. package/tsconfig.tsbuildinfo +1 -1
  50. package/typedefs/content.graphql +1 -0
  51. package/typedefs/topper.graphql +3 -0
@@ -6,6 +6,7 @@ declare const resolvers: {
6
6
  backgroundColour: (topper: import("../model/Topper").Topper) => "paper" | "wheat" | "white" | "black" | "claret" | "oxford" | "slate" | "crimson" | "sky" | "matisse";
7
7
  displayConcept: (topper: import("../model/Topper").Topper) => import("../model/Concept").Concept | null;
8
8
  followButtonVariant: (topper: import("../model/Topper").Topper) => "standard" | "inverse" | "opinion" | "alphaville" | "monochrome" | "inverse-monochrome";
9
+ genreConcept: (topper: import("../model/Topper").Topper) => import("../model/Concept").Concept | null;
9
10
  };
10
11
  TopperWithImages: {
11
12
  images: (topper: import("../model/Topper").Topper) => import("../model/Image").CAPIImage[];
@@ -20,10 +21,10 @@ declare const resolvers: {
20
21
  genreConcept: (topper: import("../model/Topper").Topper) => import("../model/Concept").Concept | null;
21
22
  };
22
23
  PodcastTopper: {
23
- headshot: (topper: import("../model/Topper").Topper, args: Partial<import("../generated").PodcastTopperHeadshotArgs>) => string | null;
24
+ headshot: (topper: import("../model/Topper").Topper, args: Partial<import("../generated").PodcastTopperHeadshotArgs>) => Promise<string | null>;
24
25
  };
25
26
  OpinionTopper: {
26
- headshot(topper: import("../model/Topper").Topper, args: Partial<import("../generated").OpinionTopperHeadshotArgs>): Promise<string | null>;
27
+ headshot: (topper: import("../model/Topper").Topper, args: Partial<import("../generated").OpinionTopperHeadshotArgs>) => Promise<string | null>;
27
28
  columnist: (topper: import("../model/Topper").Topper) => import("../model/Concept").Concept | null;
28
29
  };
29
30
  TopperWithPackage: {
@@ -8,6 +8,7 @@ const resolvers = {
8
8
  backgroundColour: (topper) => topper.backgroundColour(),
9
9
  displayConcept: (topper) => topper.displayConcept(),
10
10
  followButtonVariant: (topper) => topper.followButtonVariant(),
11
+ genreConcept: (topper) => topper.genreConcept(),
11
12
  },
12
13
  TopperWithImages: {
13
14
  images: (topper) => topper.images(),
@@ -25,9 +26,7 @@ const resolvers = {
25
26
  headshot: (topper, args) => topper.headshot(args),
26
27
  },
27
28
  OpinionTopper: {
28
- async headshot(topper, args) {
29
- return topper.columnist()?.headshot(args) || null;
30
- },
29
+ headshot: (topper, args) => topper.headshot(args),
31
30
  columnist: (topper) => topper.columnist(),
32
31
  },
33
32
  TopperWithPackage: {
@@ -1 +1 @@
1
- {"version":3,"file":"topper.js","sourceRoot":"","sources":["../../src/resolvers/topper.ts"],"names":[],"mappings":";;AAUA,MAAM,SAAS,GAAG;IAChB,MAAM,EAAE;QACN,aAAa,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE;QACxC,QAAQ,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE;QACvC,KAAK,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE;QACjC,gBAAgB,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,gBAAgB,EAAE;QACvD,cAAc,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,cAAc,EAAE;QACnD,mBAAmB,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,mBAAmB,EAAE;KAC9D;IAED,gBAAgB,EAAE;QAChB,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE;QACnC,aAAa,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,aAAa,EAAE;KAClD;IAED,eAAe,EAAE;QACf,eAAe,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,eAAe,EAAE;QACrD,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE;KACpC;IAED,eAAe,EAAE;QACf,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,EAAE;QAC/C,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,EAAE;KAChD;IAED,aAAa,EAAE;QACb,QAAQ,EAAE,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;KAClD;IAED,aAAa,EAAE;QACb,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,IAAI;YACzB,OAAO,MAAM,CAAC,SAAS,EAAE,EAAE,QAAQ,CAAC,IAAI,CAAC,IAAI,IAAI,CAAA;QACnD,CAAC;QACD,SAAS,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,EAAE;KAC1C;IAED,iBAAiB,EAAE;QACjB,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK;KAC1C;CASF,CAAA;AAED,kBAAe,SAAS,CAAA"}
1
+ {"version":3,"file":"topper.js","sourceRoot":"","sources":["../../src/resolvers/topper.ts"],"names":[],"mappings":";;AAUA,MAAM,SAAS,GAAG;IAChB,MAAM,EAAE;QACN,aAAa,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,IAAI,EAAE;QACxC,QAAQ,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,EAAE;QACvC,KAAK,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,KAAK,EAAE;QACjC,gBAAgB,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,gBAAgB,EAAE;QACvD,cAAc,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,cAAc,EAAE;QACnD,mBAAmB,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,mBAAmB,EAAE;QAC7D,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,EAAE;KAChD;IAED,gBAAgB,EAAE;QAChB,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE;QACnC,aAAa,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,aAAa,EAAE;KAClD;IAED,eAAe,EAAE;QACf,eAAe,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,eAAe,EAAE;QACrD,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE;KACpC;IAED,eAAe,EAAE;QACf,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,EAAE;QAC/C,YAAY,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,YAAY,EAAE;KAChD;IAED,aAAa,EAAE;QACb,QAAQ,EAAE,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;KAClD;IAED,aAAa,EAAE;QACb,QAAQ,EAAE,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,CAAC;QACjD,SAAS,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,SAAS,EAAE;KAC1C;IAED,iBAAiB,EAAE;QACjB,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK;KAC1C;CASF,CAAA;AAED,kBAAe,SAAS,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/cp-content-pipeline-schema",
3
- "version": "1.2.4",
3
+ "version": "1.3.1",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -120,6 +120,7 @@ export type BasicTopper = Topper & {
120
120
  readonly backgroundColour?: Maybe<Scalars['TopperBackgroundColour']['output']>;
121
121
  readonly displayConcept?: Maybe<Concept>;
122
122
  readonly followButtonVariant?: Maybe<Scalars['FollowButtonVariant']['output']>;
123
+ readonly genreConcept?: Maybe<Concept>;
123
124
  readonly headline: Scalars['String']['output'];
124
125
  readonly intro?: Maybe<RichText>;
125
126
  readonly textShadow?: Maybe<Scalars['Boolean']['output']>;
@@ -561,6 +562,7 @@ export type LiveBlogPost = Content & {
561
562
  readonly byline?: Maybe<StructuredContent>;
562
563
  readonly canBeSyndicated?: Maybe<Scalars['CanBeSyndicated']['output']>;
563
564
  readonly commentsEnabled?: Maybe<Scalars['Boolean']['output']>;
565
+ readonly containedIn?: Maybe<Content>;
564
566
  readonly design?: Maybe<Design>;
565
567
  readonly firstPublishedDate: Scalars['String']['output'];
566
568
  readonly id: Scalars['String']['output'];
@@ -612,6 +614,7 @@ export type OpinionTopper = Topper & TopperWithHeadshot & TopperWithTheme & {
612
614
  readonly columnist?: Maybe<Concept>;
613
615
  readonly displayConcept?: Maybe<Concept>;
614
616
  readonly followButtonVariant?: Maybe<Scalars['FollowButtonVariant']['output']>;
617
+ readonly genreConcept?: Maybe<Concept>;
615
618
  readonly headline: Scalars['String']['output'];
616
619
  readonly headshot?: Maybe<Scalars['String']['output']>;
617
620
  readonly intro?: Maybe<RichText>;
@@ -819,6 +822,7 @@ export type Topper = {
819
822
  readonly backgroundColour?: Maybe<Scalars['TopperBackgroundColour']['output']>;
820
823
  readonly displayConcept?: Maybe<Concept>;
821
824
  readonly followButtonVariant?: Maybe<Scalars['FollowButtonVariant']['output']>;
825
+ readonly genreConcept?: Maybe<Concept>;
822
826
  readonly headline: Scalars['String']['output'];
823
827
  readonly intro?: Maybe<RichText>;
824
828
  readonly textShadow?: Maybe<Scalars['Boolean']['output']>;
@@ -973,7 +977,7 @@ export type ResolversInterfaceTypes<RefType extends Record<string, unknown>> = R
973
977
  Image: ( ImageDesktop ) | ( ImageLandscape ) | ( ImageMobile ) | ( ImagePortrait ) | ( ImageSquare ) | ( ImageSquareFtEdit ) | ( ImageStandard ) | ( ImageStandardInline ) | ( ImageWide );
974
978
  Picture: ( Omit<PictureFullBleed, 'fallbackImage' | 'images'> & { fallbackImage: RefType['Image'], images: ReadonlyArray<RefType['Image']> } ) | ( Omit<PictureInline, 'fallbackImage' | 'images'> & { fallbackImage: RefType['Image'], images: ReadonlyArray<RefType['Image']> } ) | ( Omit<PictureStandard, 'fallbackImage' | 'images'> & { fallbackImage: RefType['Image'], images: ReadonlyArray<RefType['Image']> } );
975
979
  Reference: ( ReferenceWithCAPIData<ContentTree.Flourish> ) | ( ReferenceWithCAPIData<ContentTree.ImageSet> ) | ( ReferenceWithCAPIData<ContentTree.LayoutImage> ) | ( ReferenceWithCAPIData<ContentTree.ImageSet> ) | ( ReferenceWithCAPIData<RawImageNode> ) | ( ReferenceWithCAPIData<ContentTree.Recommended> ) | ( ReferenceWithCAPIData<ContentTree.ScrollyImage> ) | ( ReferenceWithCAPIData<ContentTree.Tweet> ) | ( ReferenceWithCAPIData<VideoNode> );
976
- Topper: ( Omit<BasicTopper, 'displayConcept' | 'intro'> & { displayConcept?: Maybe<RefType['Concept']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<BrandedTopper, 'brandConcept' | 'displayConcept' | 'genreConcept' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, genreConcept?: Maybe<RefType['Concept']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<DeepLandscapeTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<DeepPortraitTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<FullBleedTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( TopperModel ) | ( TopperModel ) | ( Omit<SplitTextTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } );
980
+ Topper: ( Omit<BasicTopper, 'displayConcept' | 'genreConcept' | 'intro'> & { displayConcept?: Maybe<RefType['Concept']>, genreConcept?: Maybe<RefType['Concept']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<BrandedTopper, 'brandConcept' | 'displayConcept' | 'genreConcept' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, genreConcept?: Maybe<RefType['Concept']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<DeepLandscapeTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<DeepPortraitTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<FullBleedTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( TopperModel ) | ( TopperModel ) | ( Omit<SplitTextTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } );
977
981
  TopperWithBrand: ( Omit<BrandedTopper, 'brandConcept' | 'displayConcept' | 'genreConcept' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, genreConcept?: Maybe<RefType['Concept']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<DeepLandscapeTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<DeepPortraitTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<FullBleedTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( TopperModel ) | ( Omit<SplitTextTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } );
978
982
  TopperWithHeadshot: ( TopperModel ) | ( TopperModel );
979
983
  TopperWithImages: ( Omit<DeepLandscapeTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<DeepPortraitTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<FullBleedTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } ) | ( Omit<SplitTextTopper, 'brandConcept' | 'displayConcept' | 'fallbackImage' | 'genreConcept' | 'images' | 'intro'> & { brandConcept?: Maybe<RefType['Concept']>, displayConcept?: Maybe<RefType['Concept']>, fallbackImage?: Maybe<RefType['Image']>, genreConcept?: Maybe<RefType['Concept']>, images: ReadonlyArray<RefType['Image']>, intro?: Maybe<RefType['RichText']> } );
@@ -988,7 +992,7 @@ export type ResolversTypes = ResolversObject<{
988
992
  AltTitle: ResolverTypeWrapper<AltTitle>;
989
993
  Article: ResolverTypeWrapper<CapiResponse>;
990
994
  Audio: ResolverTypeWrapper<CapiResponse>;
991
- BasicTopper: ResolverTypeWrapper<Omit<BasicTopper, 'displayConcept' | 'intro'> & { displayConcept?: Maybe<ResolversTypes['Concept']>, intro?: Maybe<ResolversTypes['RichText']> }>;
995
+ BasicTopper: ResolverTypeWrapper<Omit<BasicTopper, 'displayConcept' | 'genreConcept' | 'intro'> & { displayConcept?: Maybe<ResolversTypes['Concept']>, genreConcept?: Maybe<ResolversTypes['Concept']>, intro?: Maybe<ResolversTypes['RichText']> }>;
992
996
  Boolean: ResolverTypeWrapper<Scalars['Boolean']['output']>;
993
997
  BrandedTopper: ResolverTypeWrapper<Omit<BrandedTopper, 'brandConcept' | 'displayConcept' | 'genreConcept' | 'intro'> & { brandConcept?: Maybe<ResolversTypes['Concept']>, displayConcept?: Maybe<ResolversTypes['Concept']>, genreConcept?: Maybe<ResolversTypes['Concept']>, intro?: Maybe<ResolversTypes['RichText']> }>;
994
998
  CanBeSyndicated: ResolverTypeWrapper<Scalars['CanBeSyndicated']['output']>;
@@ -1066,7 +1070,7 @@ export type ResolversParentTypes = ResolversObject<{
1066
1070
  AltTitle: AltTitle;
1067
1071
  Article: CapiResponse;
1068
1072
  Audio: CapiResponse;
1069
- BasicTopper: Omit<BasicTopper, 'displayConcept' | 'intro'> & { displayConcept?: Maybe<ResolversParentTypes['Concept']>, intro?: Maybe<ResolversParentTypes['RichText']> };
1073
+ BasicTopper: Omit<BasicTopper, 'displayConcept' | 'genreConcept' | 'intro'> & { displayConcept?: Maybe<ResolversParentTypes['Concept']>, genreConcept?: Maybe<ResolversParentTypes['Concept']>, intro?: Maybe<ResolversParentTypes['RichText']> };
1070
1074
  Boolean: Scalars['Boolean']['output'];
1071
1075
  BrandedTopper: Omit<BrandedTopper, 'brandConcept' | 'displayConcept' | 'genreConcept' | 'intro'> & { brandConcept?: Maybe<ResolversParentTypes['Concept']>, displayConcept?: Maybe<ResolversParentTypes['Concept']>, genreConcept?: Maybe<ResolversParentTypes['Concept']>, intro?: Maybe<ResolversParentTypes['RichText']> };
1072
1076
  CanBeSyndicated: Scalars['CanBeSyndicated']['output'];
@@ -1206,6 +1210,7 @@ export type BasicTopperResolvers<ContextType = QueryContext, ParentType extends
1206
1210
  backgroundColour?: Resolver<Maybe<ResolversTypes['TopperBackgroundColour']>, ParentType, ContextType>;
1207
1211
  displayConcept?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1208
1212
  followButtonVariant?: Resolver<Maybe<ResolversTypes['FollowButtonVariant']>, ParentType, ContextType>;
1213
+ genreConcept?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1209
1214
  headline?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
1210
1215
  intro?: Resolver<Maybe<ResolversTypes['RichText']>, ParentType, ContextType>;
1211
1216
  textShadow?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
@@ -1592,6 +1597,7 @@ export type LiveBlogPostResolvers<ContextType = QueryContext, ParentType extends
1592
1597
  byline?: Resolver<Maybe<ResolversTypes['StructuredContent']>, ParentType, ContextType, Partial<LiveBlogPostBylineArgs>>;
1593
1598
  canBeSyndicated?: Resolver<Maybe<ResolversTypes['CanBeSyndicated']>, ParentType, ContextType>;
1594
1599
  commentsEnabled?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
1600
+ containedIn?: Resolver<Maybe<ResolversTypes['Content']>, ParentType, ContextType>;
1595
1601
  design?: Resolver<Maybe<ResolversTypes['Design']>, ParentType, ContextType>;
1596
1602
  firstPublishedDate?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
1597
1603
  id?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
@@ -1630,6 +1636,7 @@ export type OpinionTopperResolvers<ContextType = QueryContext, ParentType extend
1630
1636
  columnist?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1631
1637
  displayConcept?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1632
1638
  followButtonVariant?: Resolver<Maybe<ResolversTypes['FollowButtonVariant']>, ParentType, ContextType>;
1639
+ genreConcept?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1633
1640
  headline?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
1634
1641
  headshot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType, Partial<OpinionTopperHeadshotArgs>>;
1635
1642
  intro?: Resolver<Maybe<ResolversTypes['RichText']>, ParentType, ContextType>;
@@ -1824,6 +1831,7 @@ export type TopperResolvers<ContextType = QueryContext, ParentType extends Resol
1824
1831
  backgroundColour?: Resolver<Maybe<ResolversTypes['TopperBackgroundColour']>, ParentType, ContextType>;
1825
1832
  displayConcept?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1826
1833
  followButtonVariant?: Resolver<Maybe<ResolversTypes['FollowButtonVariant']>, ParentType, ContextType>;
1834
+ genreConcept?: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
1827
1835
  headline?: Resolver<ResolversTypes['String'], ParentType, ContextType>;
1828
1836
  intro?: Resolver<Maybe<ResolversTypes['RichText']>, ParentType, ContextType>;
1829
1837
  textShadow?: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
@@ -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({
@@ -59,6 +59,11 @@ export interface Video extends ContentTree.Node {
59
59
  embedded: boolean
60
60
  }
61
61
 
62
+ export interface YoutubeVideo extends ContentTree.Node {
63
+ type: 'youtube-video'
64
+ url: string
65
+ }
66
+
62
67
  export interface Clip extends ContentTree.Node {
63
68
  type: 'clip'
64
69
  url: string
@@ -158,6 +163,7 @@ export type AnyNode =
158
163
  | TableRow
159
164
  | TableCell
160
165
  | Video
166
+ | YoutubeVideo
161
167
  | Clip
162
168
  | MainImage
163
169
  | RawImage
@@ -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
  },
@@ -179,6 +179,18 @@ const commonTagMappings: TagMappings = {
179
179
  id: $el.attr('url') || '',
180
180
  embedded: $el.attr('data-embedded') === 'true' || true,
181
181
  }),
182
+ 'a[data-asset-type="video"]': ($el) => {
183
+ const url = $el.attr('href')
184
+
185
+ if (url) {
186
+ return {
187
+ type: 'youtube-video',
188
+ url,
189
+ }
190
+ }
191
+
192
+ return []
193
+ },
182
194
  'ft-content[type="http://www.ft.com/ontology/content/clip"]': ($el) => ({
183
195
  type: 'clip',
184
196
  url: $el.attr('href') || '',
@@ -207,6 +219,12 @@ const commonTagMappings: TagMappings = {
207
219
  timestamp: $el.attr('data-time-stamp') || '',
208
220
  }
209
221
  },
222
+ 'ft-related': ($el) => {
223
+ return {
224
+ id: $el.attr('url') ?? '',
225
+ type: 'recommended',
226
+ }
227
+ },
210
228
  recommended: ($el) => {
211
229
  const $firstLink = $el.find('ft-content').first()
212
230
  const $heading = $el.find('recommended-title').first()
@@ -67,6 +67,10 @@ const resolvers = {
67
67
  containedIn: (parent) => parent.containedIn(),
68
68
  },
69
69
 
70
+ LiveBlogPost: {
71
+ containedIn: (parent) => parent.containedIn(),
72
+ },
73
+
70
74
  LiveBlogPackage: {
71
75
  liveBlogPosts: (parent) => parent.liveBlogPosts(),
72
76
  pinnedPost: (parent) => parent.pinnedPost(),
@@ -87,6 +91,7 @@ const resolvers = {
87
91
  } satisfies {
88
92
  Article: ArticleResolvers
89
93
  Placeholder: PlaceholderResolvers
94
+ LiveBlogPost: ArticleResolvers
90
95
  Content: ContentResolvers
91
96
  LiveBlogPackage: LiveBlogPackageResolvers
92
97
  ContentPackage: ContentPackageResolvers
@@ -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