@financial-times/cp-content-pipeline-schema 2.9.2 → 2.10.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 +13 -0
  2. package/lib/generated/index.d.ts +87 -2
  3. package/lib/model/CapiResponse.d.ts +6 -0
  4. package/lib/model/CapiResponse.js +5 -0
  5. package/lib/model/CapiResponse.js.map +1 -1
  6. package/lib/model/FlourishSource.d.ts +22 -0
  7. package/lib/model/FlourishSource.js +89 -0
  8. package/lib/model/FlourishSource.js.map +1 -0
  9. package/lib/model/FlourishSource.test.d.ts +1 -0
  10. package/lib/model/FlourishSource.test.js +67 -0
  11. package/lib/model/FlourishSource.test.js.map +1 -0
  12. package/lib/model/LeadFlourish.d.ts +13 -0
  13. package/lib/model/LeadFlourish.js +35 -0
  14. package/lib/model/LeadFlourish.js.map +1 -0
  15. package/lib/model/LeadFlourish.test.d.ts +1 -0
  16. package/lib/model/LeadFlourish.test.js +62 -0
  17. package/lib/model/LeadFlourish.test.js.map +1 -0
  18. package/lib/model/Topper.d.ts +4 -1
  19. package/lib/model/Topper.js +15 -0
  20. package/lib/model/Topper.js.map +1 -1
  21. package/lib/model/Topper.test.js +21 -0
  22. package/lib/model/Topper.test.js.map +1 -1
  23. package/lib/model/schemas/capi/article.d.ts +28 -0
  24. package/lib/model/schemas/capi/article.js +1 -0
  25. package/lib/model/schemas/capi/article.js.map +1 -1
  26. package/lib/model/schemas/capi/base-schema.d.ts +41 -0
  27. package/lib/model/schemas/capi/base-schema.js +8 -1
  28. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  29. package/lib/model/schemas/capi/content-package.d.ts +28 -0
  30. package/lib/model/schemas/capi/content-package.js +1 -0
  31. package/lib/model/schemas/capi/content-package.js.map +1 -1
  32. package/lib/model/schemas/capi/live-blog-package.d.ts +5 -0
  33. package/lib/model/schemas/capi/placeholder.d.ts +5 -0
  34. package/lib/resolvers/content-tree/references/Flourish.d.ts +8 -2
  35. package/lib/resolvers/content-tree/references/Flourish.js +15 -40
  36. package/lib/resolvers/content-tree/references/Flourish.js.map +1 -1
  37. package/lib/resolvers/content-tree/references/Flourish.test.js +0 -30
  38. package/lib/resolvers/content-tree/references/Flourish.test.js.map +1 -1
  39. package/lib/resolvers/index.d.ts +36 -9
  40. package/lib/resolvers/index.js +2 -0
  41. package/lib/resolvers/index.js.map +1 -1
  42. package/lib/resolvers/leadFlourish.d.ts +16 -0
  43. package/lib/resolvers/leadFlourish.js +28 -0
  44. package/lib/resolvers/leadFlourish.js.map +1 -0
  45. package/lib/resolvers/topper.d.ts +23 -9
  46. package/lib/resolvers/topper.js +6 -0
  47. package/lib/resolvers/topper.js.map +1 -1
  48. package/package.json +1 -1
  49. package/queries/article.graphql +26 -0
  50. package/src/generated/index.ts +93 -2
  51. package/src/model/CapiResponse.ts +6 -0
  52. package/src/model/FlourishSource.test.ts +93 -0
  53. package/src/model/FlourishSource.ts +103 -0
  54. package/src/model/LeadFlourish.test.ts +71 -0
  55. package/src/model/LeadFlourish.ts +30 -0
  56. package/src/model/Topper.test.ts +26 -0
  57. package/src/model/Topper.ts +18 -0
  58. package/src/model/schemas/capi/article.ts +1 -0
  59. package/src/model/schemas/capi/base-schema.ts +8 -0
  60. package/src/model/schemas/capi/content-package.ts +1 -0
  61. package/src/resolvers/content-tree/references/Flourish.test.ts +2 -49
  62. package/src/resolvers/content-tree/references/Flourish.ts +15 -59
  63. package/src/resolvers/index.ts +2 -0
  64. package/src/resolvers/leadFlourish.ts +31 -0
  65. package/src/resolvers/topper.ts +10 -0
  66. package/src/types/internal-content.d.ts +2 -0
  67. package/tsconfig.tsbuildinfo +1 -1
  68. package/typedefs/leadFlouish.graphql +29 -0
  69. package/typedefs/topper.graphql +35 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@financial-times/cp-content-pipeline-schema",
3
- "version": "2.9.2",
3
+ "version": "2.10.0",
4
4
  "description": "",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {
@@ -81,6 +81,23 @@ fragment Person on Person {
81
81
  streamPage
82
82
  }
83
83
 
84
+ fragment FlourishSource on FlourishSource {
85
+ url
86
+ type
87
+ format
88
+ width
89
+ height
90
+ }
91
+
92
+ fragment LeadFlourish on LeadFlourish {
93
+ type
94
+ id
95
+ description
96
+ fallbackImage {
97
+ ...FlourishSource
98
+ }
99
+ }
100
+
84
101
  fragment Topper on Topper {
85
102
  __typename
86
103
  headline
@@ -183,6 +200,15 @@ fragment Topper on Topper {
183
200
  isLargeHeadline
184
201
  layout
185
202
  }
203
+
204
+ ... on TopperWithFlourish {
205
+ layout
206
+ layoutWidth
207
+ leadFlourish {
208
+ ...LeadFlourish
209
+ }
210
+ }
211
+
186
212
  ... on TopperWithPackage {
187
213
  design
188
214
  }
@@ -7,6 +7,8 @@ import type { Clip as ClipModel } from '../model/Clip';
7
7
  import type { Picture as PictureModel } from '../model/Picture';
8
8
  import type { RichText as RichTextModel } from '../model/RichText';
9
9
  import type { Topper as TopperModel } from '../model/Topper';
10
+ import type { LeadFlourish as LeadFlourishModel } from '../model/LeadFlourish';
11
+ import type { FlourishSource as FlourishSourceModel } from '../model/FlourishSource';
10
12
  import type { ContentTree } from '@financial-times/content-tree';
11
13
  import type { ReferenceWithCAPIData } from '../resolvers/content-tree/references';
12
14
  import type { Video as VideoNode, ClipSet as ClipSetNode, OldClip as OldClipNode, RawImage as RawImageNode } from '../resolvers/content-tree/Workarounds';
@@ -549,6 +551,19 @@ export type FlourishFallback = {
549
551
  readonly width?: Maybe<Scalars['Int']['output']>;
550
552
  };
551
553
 
554
+ export type FlourishSource = {
555
+ /** The format of the source, eg. 'standard'. */
556
+ readonly format?: Maybe<Scalars['String']['output']>;
557
+ /** The height of the source. */
558
+ readonly height?: Maybe<Scalars['Int']['output']>;
559
+ /** The type of the source, e.g. visualisation for leadFlourish */
560
+ readonly type?: Maybe<Scalars['String']['output']>;
561
+ /** The Origami image service url */
562
+ readonly url?: Maybe<Scalars['String']['output']>;
563
+ /** The width of the source. */
564
+ readonly width?: Maybe<Scalars['Int']['output']>;
565
+ };
566
+
552
567
  export type FullBleedTopper = Topper & TopperWithBrand & TopperWithImages & TopperWithTheme & {
553
568
  /** Whether the topper should have a background box. */
554
569
  readonly backgroundBox?: Maybe<Scalars['Boolean']['output']>;
@@ -909,6 +924,16 @@ export type LayoutImage = Reference & {
909
924
  readonly type: Scalars['String']['output'];
910
925
  };
911
926
 
927
+ export type LeadFlourish = {
928
+ /** The description of the Flourish chart. */
929
+ readonly description?: Maybe<Scalars['String']['output']>;
930
+ readonly fallbackImage: FlourishSource;
931
+ /** The id of the Flourish chart. */
932
+ readonly id?: Maybe<Scalars['String']['output']>;
933
+ /** The type of the chart, eg. 'visualisation'. */
934
+ readonly type?: Maybe<Scalars['String']['output']>;
935
+ };
936
+
912
937
  export type LiveBlogPackage = Content & {
913
938
  /** A scalar representing the access level of the article, eg. 'free'. */
914
939
  readonly accessLevel?: Maybe<Scalars['AccessLevel']['output']>;
@@ -1462,6 +1487,31 @@ export type TopperWithBrand = {
1462
1487
  readonly genreConcept?: Maybe<Concept>;
1463
1488
  };
1464
1489
 
1490
+ export type TopperWithFlourish = Topper & {
1491
+ /** Whether the topper should have a background box. */
1492
+ readonly backgroundBox?: Maybe<Scalars['Boolean']['output']>;
1493
+ /** The background colour of the topper. */
1494
+ readonly backgroundColour?: Maybe<Scalars['TopperBackgroundColour']['output']>;
1495
+ /** The concept object to be displayed, eg. {'type': 'TOPIC', 'prefLabel': 'Non-dom tax status', ...}. */
1496
+ readonly displayConcept?: Maybe<Concept>;
1497
+ /** The variant of the follow button to be displayed on the topper. */
1498
+ readonly followButtonVariant?: Maybe<Scalars['FollowButtonVariant']['output']>;
1499
+ /** The concept object of the genre, eg. {'type': 'GENRE', 'prefLabel': 'Opinion', ...}. */
1500
+ readonly genreConcept?: Maybe<Concept>;
1501
+ /** The headline text of the topper. */
1502
+ readonly headline: Scalars['String']['output'];
1503
+ /** An abstract syntax tree of the introduction text. */
1504
+ readonly intro?: Maybe<RichText>;
1505
+ /** The layout type of the topper, eg. 'flourish'. */
1506
+ readonly layout?: Maybe<Scalars['String']['output']>;
1507
+ /** The layout width of the topper, eg. 'full-grid'. */
1508
+ readonly layoutWidth?: Maybe<Scalars['String']['output']>;
1509
+ /** The flourish object to be displayed, eg. {'type': 'visualisation', ...}. */
1510
+ readonly leadFlourish?: Maybe<LeadFlourish>;
1511
+ /** Whether the topper should have a text shadow. */
1512
+ readonly textShadow?: Maybe<Scalars['Boolean']['output']>;
1513
+ };
1514
+
1465
1515
  export type TopperWithHeadshot = {
1466
1516
  /** The url of the headshot image, eg. for columns with one key author. */
1467
1517
  readonly headshot?: Maybe<Scalars['String']['output']>;
@@ -1650,7 +1700,7 @@ export type ResolversInterfaceTypes<RefType extends Record<string, unknown>> = R
1650
1700
  Image: ( ImageModel ) | ( ImageModel ) | ( ImageModel ) | ( ImageModel ) | ( ImageModel ) | ( ImageModel ) | ( ImageModel ) | ( ImageModel ) | ( ImageModel );
1651
1701
  Picture: ( PictureModel ) | ( PictureModel ) | ( PictureModel );
1652
1702
  Reference: ( ReferenceWithCAPIData<ClipSetNode|OldClipNode> ) | ( 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> );
1653
- Topper: ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel );
1703
+ Topper: ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel );
1654
1704
  TopperWithBrand: ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel );
1655
1705
  TopperWithHeadshot: ( TopperModel ) | ( TopperModel );
1656
1706
  TopperWithImages: ( TopperModel ) | ( TopperModel ) | ( TopperModel ) | ( TopperModel );
@@ -1685,6 +1735,7 @@ export type ResolversTypes = ResolversObject<{
1685
1735
  Float: ResolverTypeWrapper<Scalars['Float']['output']>;
1686
1736
  Flourish: ResolverTypeWrapper<ReferenceWithCAPIData<ContentTree.Flourish>>;
1687
1737
  FlourishFallback: ResolverTypeWrapper<FlourishFallback>;
1738
+ FlourishSource: ResolverTypeWrapper<FlourishSourceModel>;
1688
1739
  FollowButtonVariant: ResolverTypeWrapper<Scalars['FollowButtonVariant']['output']>;
1689
1740
  FullBleedTopper: ResolverTypeWrapper<TopperModel>;
1690
1741
  ID: ResolverTypeWrapper<Scalars['ID']['output']>;
@@ -1706,6 +1757,7 @@ export type ResolversTypes = ResolversObject<{
1706
1757
  Int: ResolverTypeWrapper<Scalars['Int']['output']>;
1707
1758
  JSON: ResolverTypeWrapper<Scalars['JSON']['output']>;
1708
1759
  LayoutImage: ResolverTypeWrapper<ReferenceWithCAPIData<ContentTree.LayoutImage>>;
1760
+ LeadFlourish: ResolverTypeWrapper<LeadFlourishModel>;
1709
1761
  LiveBlogPackage: ResolverTypeWrapper<CapiResponse>;
1710
1762
  LiveBlogPost: ResolverTypeWrapper<CapiResponse>;
1711
1763
  MainImage: ResolverTypeWrapper<ReferenceWithCAPIData<ContentTree.ImageSet>>;
@@ -1736,6 +1788,7 @@ export type ResolversTypes = ResolversObject<{
1736
1788
  Topper: ResolverTypeWrapper<TopperModel>;
1737
1789
  TopperBackgroundColour: ResolverTypeWrapper<Scalars['TopperBackgroundColour']['output']>;
1738
1790
  TopperWithBrand: ResolverTypeWrapper<TopperModel>;
1791
+ TopperWithFlourish: ResolverTypeWrapper<TopperModel>;
1739
1792
  TopperWithHeadshot: ResolverTypeWrapper<ResolversInterfaceTypes<ResolversTypes>['TopperWithHeadshot']>;
1740
1793
  TopperWithImages: ResolverTypeWrapper<TopperModel>;
1741
1794
  TopperWithPackage: ResolverTypeWrapper<TopperModel>;
@@ -1772,6 +1825,7 @@ export type ResolversParentTypes = ResolversObject<{
1772
1825
  Float: Scalars['Float']['output'];
1773
1826
  Flourish: ReferenceWithCAPIData<ContentTree.Flourish>;
1774
1827
  FlourishFallback: FlourishFallback;
1828
+ FlourishSource: FlourishSourceModel;
1775
1829
  FollowButtonVariant: Scalars['FollowButtonVariant']['output'];
1776
1830
  FullBleedTopper: TopperModel;
1777
1831
  ID: Scalars['ID']['output'];
@@ -1793,6 +1847,7 @@ export type ResolversParentTypes = ResolversObject<{
1793
1847
  Int: Scalars['Int']['output'];
1794
1848
  JSON: Scalars['JSON']['output'];
1795
1849
  LayoutImage: ReferenceWithCAPIData<ContentTree.LayoutImage>;
1850
+ LeadFlourish: LeadFlourishModel;
1796
1851
  LiveBlogPackage: CapiResponse;
1797
1852
  LiveBlogPost: CapiResponse;
1798
1853
  MainImage: ReferenceWithCAPIData<ContentTree.ImageSet>;
@@ -1823,6 +1878,7 @@ export type ResolversParentTypes = ResolversObject<{
1823
1878
  Topper: TopperModel;
1824
1879
  TopperBackgroundColour: Scalars['TopperBackgroundColour']['output'];
1825
1880
  TopperWithBrand: TopperModel;
1881
+ TopperWithFlourish: TopperModel;
1826
1882
  TopperWithHeadshot: ResolversInterfaceTypes<ResolversParentTypes>['TopperWithHeadshot'];
1827
1883
  TopperWithImages: TopperModel;
1828
1884
  TopperWithPackage: TopperModel;
@@ -2121,6 +2177,15 @@ export type FlourishFallbackResolvers<ContextType = QueryContext, ParentType ext
2121
2177
  __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2122
2178
  }>;
2123
2179
 
2180
+ export type FlourishSourceResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['FlourishSource'] = ResolversParentTypes['FlourishSource']> = ResolversObject<{
2181
+ format: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2182
+ height: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
2183
+ type: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2184
+ url: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2185
+ width: Resolver<Maybe<ResolversTypes['Int']>, ParentType, ContextType>;
2186
+ __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2187
+ }>;
2188
+
2124
2189
  export interface FollowButtonVariantScalarConfig extends GraphQLScalarTypeConfig<ResolversTypes['FollowButtonVariant'], any> {
2125
2190
  name: 'FollowButtonVariant';
2126
2191
  }
@@ -2324,6 +2389,14 @@ export type LayoutImageResolvers<ContextType = QueryContext, ParentType extends
2324
2389
  __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2325
2390
  }>;
2326
2391
 
2392
+ export type LeadFlourishResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['LeadFlourish'] = ResolversParentTypes['LeadFlourish']> = ResolversObject<{
2393
+ description: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2394
+ fallbackImage: Resolver<ResolversTypes['FlourishSource'], ParentType, ContextType>;
2395
+ id: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2396
+ type: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2397
+ __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2398
+ }>;
2399
+
2327
2400
  export type LiveBlogPackageResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['LiveBlogPackage'] = ResolversParentTypes['LiveBlogPackage']> = ResolversObject<{
2328
2401
  accessLevel: Resolver<Maybe<ResolversTypes['AccessLevel']>, ParentType, ContextType>;
2329
2402
  altStandfirst: Resolver<Maybe<ResolversTypes['AltStandfirst']>, ParentType, ContextType>;
@@ -2617,7 +2690,7 @@ export type TeaserResolvers<ContextType = QueryContext, ParentType extends Resol
2617
2690
  }>;
2618
2691
 
2619
2692
  export type TopperResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['Topper'] = ResolversParentTypes['Topper']> = ResolversObject<{
2620
- __resolveType?: TypeResolveFn<'BasicTopper' | 'BrandedTopper' | 'DeepLandscapeTopper' | 'DeepPortraitTopper' | 'FullBleedTopper' | 'OpinionTopper' | 'PodcastTopper' | 'SplitTextTopper', ParentType, ContextType>;
2693
+ __resolveType?: TypeResolveFn<'BasicTopper' | 'BrandedTopper' | 'DeepLandscapeTopper' | 'DeepPortraitTopper' | 'FullBleedTopper' | 'OpinionTopper' | 'PodcastTopper' | 'SplitTextTopper' | 'TopperWithFlourish', ParentType, ContextType>;
2621
2694
  backgroundBox: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
2622
2695
  backgroundColour: Resolver<Maybe<ResolversTypes['TopperBackgroundColour']>, ParentType, ContextType>;
2623
2696
  displayConcept: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
@@ -2638,6 +2711,21 @@ export type TopperWithBrandResolvers<ContextType = QueryContext, ParentType exte
2638
2711
  genreConcept: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
2639
2712
  }>;
2640
2713
 
2714
+ export type TopperWithFlourishResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['TopperWithFlourish'] = ResolversParentTypes['TopperWithFlourish']> = ResolversObject<{
2715
+ backgroundBox: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
2716
+ backgroundColour: Resolver<Maybe<ResolversTypes['TopperBackgroundColour']>, ParentType, ContextType>;
2717
+ displayConcept: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
2718
+ followButtonVariant: Resolver<Maybe<ResolversTypes['FollowButtonVariant']>, ParentType, ContextType>;
2719
+ genreConcept: Resolver<Maybe<ResolversTypes['Concept']>, ParentType, ContextType>;
2720
+ headline: Resolver<ResolversTypes['String'], ParentType, ContextType>;
2721
+ intro: Resolver<Maybe<ResolversTypes['RichText']>, ParentType, ContextType>;
2722
+ layout: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2723
+ layoutWidth: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
2724
+ leadFlourish: Resolver<Maybe<ResolversTypes['LeadFlourish']>, ParentType, ContextType>;
2725
+ textShadow: Resolver<Maybe<ResolversTypes['Boolean']>, ParentType, ContextType>;
2726
+ __isTypeOf?: IsTypeOfResolverFn<ParentType, ContextType>;
2727
+ }>;
2728
+
2641
2729
  export type TopperWithHeadshotResolvers<ContextType = QueryContext, ParentType extends ResolversParentTypes['TopperWithHeadshot'] = ResolversParentTypes['TopperWithHeadshot']> = ResolversObject<{
2642
2730
  __resolveType?: TypeResolveFn<'OpinionTopper' | 'PodcastTopper', ParentType, ContextType>;
2643
2731
  headshot: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType, Partial<TopperWithHeadshotHeadshotArgs>>;
@@ -2725,6 +2813,7 @@ export type Resolvers<ContextType = QueryContext> = ResolversObject<{
2725
2813
  Design: DesignResolvers<ContextType>;
2726
2814
  Flourish: FlourishResolvers<ContextType>;
2727
2815
  FlourishFallback: FlourishFallbackResolvers<ContextType>;
2816
+ FlourishSource: FlourishSourceResolvers<ContextType>;
2728
2817
  FollowButtonVariant: GraphQLScalarType;
2729
2818
  FullBleedTopper: FullBleedTopperResolvers<ContextType>;
2730
2819
  Image: ImageResolvers<ContextType>;
@@ -2744,6 +2833,7 @@ export type Resolvers<ContextType = QueryContext> = ResolversObject<{
2744
2833
  Indicators: IndicatorsResolvers<ContextType>;
2745
2834
  JSON: GraphQLScalarType;
2746
2835
  LayoutImage: LayoutImageResolvers<ContextType>;
2836
+ LeadFlourish: LeadFlourishResolvers<ContextType>;
2747
2837
  LiveBlogPackage: LiveBlogPackageResolvers<ContextType>;
2748
2838
  LiveBlogPost: LiveBlogPostResolvers<ContextType>;
2749
2839
  MainImage: MainImageResolvers<ContextType>;
@@ -2773,6 +2863,7 @@ export type Resolvers<ContextType = QueryContext> = ResolversObject<{
2773
2863
  Topper: TopperResolvers<ContextType>;
2774
2864
  TopperBackgroundColour: GraphQLScalarType;
2775
2865
  TopperWithBrand: TopperWithBrandResolvers<ContextType>;
2866
+ TopperWithFlourish: TopperWithFlourishResolvers<ContextType>;
2776
2867
  TopperWithHeadshot: TopperWithHeadshotResolvers<ContextType>;
2777
2868
  TopperWithImages: TopperWithImagesResolvers<ContextType>;
2778
2869
  TopperWithPackage: TopperWithPackageResolvers<ContextType>;
@@ -236,6 +236,12 @@ export class CapiResponse {
236
236
  return this.capiData.embeds
237
237
  return []
238
238
  }
239
+
240
+ leadFlourish() {
241
+ if ('leadFlourish' in this.capiData) return this.capiData.leadFlourish
242
+ return null
243
+ }
244
+
239
245
  byline({ vanity }: Partial<ContentUrlArgs>) {
240
246
  const bylineText = this.rawByline()
241
247
 
@@ -0,0 +1,93 @@
1
+ import { FlourishSource } from './FlourishSource'
2
+ import { QueryContext } from '..'
3
+
4
+ describe('FlourishSource', () => {
5
+ const mockFlourishData = {
6
+ id: '1234',
7
+ type: 'test-type',
8
+ description: 'Sample chart',
9
+ }
10
+
11
+ const origamiGetImageMetadataMock = jest.fn()
12
+ const context = {
13
+ systemCode: 'example-system',
14
+ dataSources: {
15
+ origami: {
16
+ getImageMetadata: origamiGetImageMetadataMock,
17
+ },
18
+ },
19
+ } as unknown as QueryContext
20
+
21
+ let flourishSource: FlourishSource
22
+
23
+ beforeEach(() => {
24
+ const MOCKED_CURRENT_TIME = 1715158800000 // 8 May 2024 09:00:00
25
+ jest.spyOn(Date, 'now').mockImplementation(() => MOCKED_CURRENT_TIME)
26
+
27
+ flourishSource = new FlourishSource(mockFlourishData, context)
28
+ })
29
+
30
+ afterEach(() => {
31
+ jest.resetAllMocks()
32
+ })
33
+
34
+ it('should return the correct id', () => {
35
+ expect(flourishSource.id()).toBe('1234')
36
+ })
37
+
38
+ it('should return the correct type', () => {
39
+ expect(flourishSource.type()).toBe('test-type')
40
+ })
41
+
42
+ it('should return the correct format', () => {
43
+ expect(flourishSource.format()).toBe('standard')
44
+ })
45
+
46
+ it('should return the correct flourishUrl', () => {
47
+ expect(flourishSource.flourishUrl()).toBe(
48
+ 'https://public.flourish.studio/test-type/1234/thumbnail?cacheBuster=5717196'
49
+ )
50
+ })
51
+
52
+ it('should return the correct width', async () => {
53
+ const width = await flourishSource.width()
54
+ expect(width).toBe(2626)
55
+ })
56
+
57
+ it('should return the correct height', async () => {
58
+ const height = await flourishSource.height()
59
+ expect(height).toBe(1459)
60
+ })
61
+
62
+ it('should return the correct url', async () => {
63
+ const url = await flourishSource.url()
64
+ // Regex checks for flourish url whilst leaving spaces for arguments
65
+ // https://public.flourish.studio/{type}/{id}/thumbnail
66
+ const flourishImageUrlRegex = new RegExp(
67
+ /https:\/\/public.flourish.studio\/.*\/.*\/thumbnail/
68
+ )
69
+
70
+ expect(decodeURIComponent(url)).toMatch(flourishImageUrlRegex)
71
+ })
72
+
73
+ it('should bust the cache of the fallback image every five minutes', async () => {
74
+ const oldCacheBuster = '5717196'
75
+ const newCacheBuster = '5717197'
76
+
77
+ expect(flourishSource.flourishUrl()).toBe(
78
+ `https://public.flourish.studio/test-type/1234/thumbnail?cacheBuster=${oldCacheBuster}`
79
+ )
80
+
81
+ let newFlourishSource = new FlourishSource(mockFlourishData, context)
82
+ jest.spyOn(Date, 'now').mockImplementation(() => 1715159040000) // 8 May 2024 09:04:00
83
+ expect(newFlourishSource.flourishUrl()).toBe(
84
+ `https://public.flourish.studio/test-type/1234/thumbnail?cacheBuster=${oldCacheBuster}`
85
+ )
86
+
87
+ newFlourishSource = new FlourishSource(mockFlourishData, context)
88
+ jest.spyOn(Date, 'now').mockImplementation(() => 1715159100000) // 8 May 2024 09:05:00
89
+ expect(newFlourishSource.flourishUrl()).toBe(
90
+ `https://public.flourish.studio/test-type/1234/thumbnail?cacheBuster=${newCacheBuster}`
91
+ )
92
+ })
93
+ })
@@ -0,0 +1,103 @@
1
+ import imageServiceUrl from '../helpers/imageService'
2
+ import isError from '../helpers/isError'
3
+ import { OperationalError } from '@dotcom-reliability-kit/errors'
4
+ import type { QueryContext } from '..'
5
+
6
+ type ImageMetadata = {
7
+ width: number
8
+ height: number
9
+ }
10
+
11
+ type FlourishData =
12
+ | {
13
+ id: string
14
+ type?: string
15
+ description?: string
16
+ }
17
+ | null
18
+ | undefined
19
+
20
+ export class FlourishSource {
21
+ #systemCode: string
22
+ private imageMetadata?: ImageMetadata
23
+
24
+ constructor(
25
+ private flourishData: FlourishData,
26
+ private context: QueryContext
27
+ ) {
28
+ this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
29
+ }
30
+
31
+ id() {
32
+ return this.flourishData?.id || ''
33
+ }
34
+
35
+ type() {
36
+ return this.flourishData?.type || 'flourish'
37
+ }
38
+
39
+ format() {
40
+ return 'standard'
41
+ }
42
+
43
+ flourishUrl() {
44
+ // Timestamp rounded to the nearest 5 minutes to bust the cache every five minutes
45
+ const cacheBusterTimestamp = Math.floor(Date.now() / (5 * 60 * 1000))
46
+
47
+ return `https://public.flourish.studio/${this.type()}/${this.id()}/thumbnail?cacheBuster=${cacheBusterTimestamp}`
48
+ }
49
+
50
+ private async getImageMetadata(): Promise<ImageMetadata> {
51
+ const DEFAULT_WIDTH = 2626
52
+ const DEFAULT_HEIGHT = 1459
53
+
54
+ if (this.imageMetadata) {
55
+ return this.imageMetadata
56
+ }
57
+
58
+ try {
59
+ const metadata = await this.context.dataSources.origami.getImageMetadata(
60
+ this.flourishUrl()
61
+ )
62
+ this.imageMetadata = {
63
+ width: metadata?.width || DEFAULT_WIDTH,
64
+ height: metadata?.height || DEFAULT_HEIGHT,
65
+ }
66
+ } catch (error) {
67
+ if (isError(error)) {
68
+ this.context.logger.warn({
69
+ event: 'RECOVERABLE_ERROR',
70
+ error: new OperationalError({
71
+ code: 'FLOURISH_IMAGE_METADATA_ERROR',
72
+ message: `Error getting image dimensions for Flourish fallback image ${this.flourishUrl()}`,
73
+ cause: error,
74
+ }),
75
+ })
76
+ }
77
+ this.imageMetadata = {
78
+ width: DEFAULT_WIDTH,
79
+ height: DEFAULT_HEIGHT,
80
+ }
81
+ }
82
+ return this.imageMetadata
83
+ }
84
+ async width() {
85
+ const imageMetadata = await this.getImageMetadata()
86
+ return imageMetadata.width
87
+ }
88
+
89
+ async height() {
90
+ const imageMetadata = await this.getImageMetadata()
91
+ return imageMetadata.height
92
+ }
93
+
94
+ async url() {
95
+ const width = await this.width()
96
+ const imageServiceWrappedUrl = imageServiceUrl({
97
+ url: this.flourishUrl(),
98
+ systemCode: 'cp-content-pipeline',
99
+ width,
100
+ })
101
+ return imageServiceWrappedUrl
102
+ }
103
+ }
@@ -0,0 +1,71 @@
1
+ import { LeadFlourish } from './LeadFlourish'
2
+ import { FlourishSource } from './FlourishSource'
3
+ import { CapiResponse } from './CapiResponse'
4
+ import { baseCapiObject } from '../fixtures/capiObject'
5
+ import type { QueryContext } from '..'
6
+ import cloneDeep from 'clone-deep'
7
+
8
+ const context = {} as unknown as QueryContext
9
+
10
+ jest.mock('./FlourishSource', () => {
11
+ return {
12
+ FlourishSource: jest.fn().mockImplementation(() => ({})),
13
+ }
14
+ })
15
+
16
+ const leadFlourishData = {
17
+ id: 'test-id',
18
+ description: 'test-description',
19
+ type: 'test-type',
20
+ }
21
+
22
+ describe('LeadFlourish', () => {
23
+ let leadFlourish: LeadFlourish
24
+
25
+ beforeEach(() => {
26
+ const clonedBase = cloneDeep(baseCapiObject)
27
+ clonedBase.leadFlourish = leadFlourishData
28
+ clonedBase.topper = {
29
+ layout: 'flourish',
30
+ layoutWidth: 'full-width',
31
+ }
32
+ const capiResponse = new CapiResponse(clonedBase, context)
33
+ leadFlourish = new LeadFlourish(capiResponse, context)
34
+ })
35
+
36
+ describe('fallbackImage', () => {
37
+ it('should instantiate a new FlourishSource', async () => {
38
+ await leadFlourish.fallbackImage()
39
+
40
+ expect(FlourishSource).toHaveBeenCalledWith(leadFlourishData, context)
41
+ })
42
+ })
43
+
44
+ describe('id', () => {
45
+ it('should return the id', () => {
46
+ const result = leadFlourish.id()
47
+
48
+ expect(result).toBe('test-id')
49
+ })
50
+ })
51
+
52
+ describe('type', () => {
53
+ it('should return the type', () => {
54
+ const result = leadFlourish.type()
55
+
56
+ expect(result).toBe('test-type')
57
+ })
58
+ })
59
+
60
+ describe('description', () => {
61
+ it('should return the description', () => {
62
+ const descriptionMock = jest.fn().mockReturnValue('flourish-description')
63
+ leadFlourish.description = descriptionMock
64
+
65
+ const result = leadFlourish.description()
66
+
67
+ expect(result).toBe('flourish-description')
68
+ expect(descriptionMock).toHaveBeenCalled()
69
+ })
70
+ })
71
+ })
@@ -0,0 +1,30 @@
1
+ import { CapiResponse } from './CapiResponse'
2
+ import type { QueryContext } from '..'
3
+ import { FlourishSource } from './FlourishSource'
4
+
5
+ export class LeadFlourish {
6
+ #systemCode: string
7
+ constructor(
8
+ private capiResponse: CapiResponse,
9
+ private context: QueryContext
10
+ ) {
11
+ this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
12
+ }
13
+
14
+ async fallbackImage() {
15
+ const flourishData = this.capiResponse.leadFlourish()
16
+ return new FlourishSource(flourishData, this.context)
17
+ }
18
+
19
+ id() {
20
+ return this.capiResponse.leadFlourish()?.id || null
21
+ }
22
+
23
+ type() {
24
+ return this.capiResponse.leadFlourish()?.type || 'visualisation'
25
+ }
26
+
27
+ description() {
28
+ return this.capiResponse.leadFlourish()?.description || ''
29
+ }
30
+ }
@@ -151,6 +151,32 @@ describe('produces the correct types', () => {
151
151
  expect(desiredColour).toEqual(actualColour)
152
152
  })
153
153
 
154
+ it('flourish topper', () => {
155
+ const clonedBase = cloneDeep(baseCapiObject)
156
+ clonedBase.leadFlourish = {
157
+ id: 'test-id',
158
+ description: 'test-description',
159
+ type: 'test-type'
160
+ }
161
+ clonedBase.topper = {
162
+ layout: 'flourish',
163
+ layoutWidth: 'full-width'
164
+ }
165
+ const capiResponse = new CapiResponse(clonedBase, context)
166
+ const topper = new Topper(capiResponse, context)
167
+
168
+ const desired = 'TopperWithFlourish'
169
+ const actual = topper.type()
170
+
171
+ expect(actual).toEqual(desired)
172
+
173
+ const LeadFlourish = topper.leadFlourish()
174
+
175
+ expect(LeadFlourish.id()).toEqual('test-id')
176
+ expect(LeadFlourish.description()).toEqual('test-description')
177
+ expect(LeadFlourish.type()).toEqual('test-type')
178
+ })
179
+
154
180
  it('live blog topper, is full bleed, large headline, paper background', () => {
155
181
  const clonedBase = cloneDeep(baseCapiObject)
156
182
  clonedBase.type = 'http://www.ft.com/ontology/content/LiveBlogPackage'
@@ -8,6 +8,7 @@ import {
8
8
  import { TopperBackgroundColour } from '../resolvers/scalars'
9
9
  import { RichText } from './RichText'
10
10
  import imageServiceUrl from '../helpers/imageService'
11
+ import { LeadFlourish } from './LeadFlourish'
11
12
  import type { TopperWithHeadshotHeadshotArgs } from '../generated'
12
13
 
13
14
  type TopperType =
@@ -19,6 +20,7 @@ type TopperType =
19
20
  | 'OpinionTopper'
20
21
  | 'BrandedTopper'
21
22
  | 'BasicTopper'
23
+ | 'TopperWithFlourish'
22
24
 
23
25
  type TopperBackgroundColourValues = LiteralUnionScalarValues<
24
26
  typeof TopperBackgroundColour
@@ -77,6 +79,10 @@ export class Topper {
77
79
  return 'SplitTextTopper'
78
80
  }
79
81
 
82
+ if (this.capiResponse.topper()?.layout === 'flourish') {
83
+ return 'TopperWithFlourish'
84
+ }
85
+
80
86
  if (
81
87
  this.capiResponse.topper()?.layout?.startsWith('full-bleed') ||
82
88
  this.capiResponse.type() === 'LiveBlogPackage'
@@ -138,6 +144,18 @@ export class Topper {
138
144
  return this.capiResponse.topper()?.layout || 'branded'
139
145
  }
140
146
 
147
+ layoutWidth() {
148
+ if (this.capiResponse.leadFlourish()) {
149
+ return this.capiResponse.topper()?.layoutWidth || 'full-grid'
150
+ } else {
151
+ return null
152
+ }
153
+ }
154
+
155
+ leadFlourish() {
156
+ return new LeadFlourish(this.capiResponse, this.context)
157
+ }
158
+
141
159
  backgroundColour(): TopperBackgroundColourValues {
142
160
  if (
143
161
  this.capiResponse.type() === 'ContentPackage' &&
@@ -36,6 +36,7 @@ const articleMediaSchema = baseMediaSchema.pick({
36
36
  mainImage: true,
37
37
  leadImages: true,
38
38
  alternativeImages: true,
39
+ leadFlourish: true,
39
40
  embeds: true,
40
41
  })
41
42