@financial-times/cp-content-pipeline-schema 0.7.22 → 0.7.24

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 (76) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/lib/generated/index.d.ts +133 -32
  3. package/lib/helpers/isError.d.ts +1 -0
  4. package/lib/helpers/isError.js +7 -0
  5. package/lib/helpers/isError.js.map +1 -0
  6. package/lib/model/CapiResponse.d.ts +5 -2
  7. package/lib/model/CapiResponse.js +42 -4
  8. package/lib/model/CapiResponse.js.map +1 -1
  9. package/lib/model/Concept.d.ts +3 -0
  10. package/lib/model/Concept.js +19 -8
  11. package/lib/model/Concept.js.map +1 -1
  12. package/lib/model/Image.js +2 -4
  13. package/lib/model/Image.js.map +1 -1
  14. package/lib/model/RichText.d.ts +1 -0
  15. package/lib/model/RichText.js +3 -0
  16. package/lib/model/RichText.js.map +1 -1
  17. package/lib/model/RichText.test.js +2 -0
  18. package/lib/model/RichText.test.js.map +1 -1
  19. package/lib/model/Topper.d.ts +1 -0
  20. package/lib/model/Topper.js +3 -0
  21. package/lib/model/Topper.js.map +1 -1
  22. package/lib/model/schemas/capi/base-schema.d.ts +18 -0
  23. package/lib/model/schemas/capi/base-schema.js +3 -0
  24. package/lib/model/schemas/capi/base-schema.js.map +1 -1
  25. package/lib/model/schemas/capi/content-package.js +1 -0
  26. package/lib/model/schemas/capi/content-package.js.map +1 -1
  27. package/lib/resolvers/content-tree/extractText.d.ts +2 -0
  28. package/lib/resolvers/content-tree/extractText.js +22 -0
  29. package/lib/resolvers/content-tree/extractText.js.map +1 -0
  30. package/lib/resolvers/content-tree/references/Flourish.d.ts +4 -3
  31. package/lib/resolvers/content-tree/references/Flourish.js +33 -8
  32. package/lib/resolvers/content-tree/references/Flourish.js.map +1 -1
  33. package/lib/resolvers/content-tree/references/RawImage.js +2 -4
  34. package/lib/resolvers/content-tree/references/RawImage.js.map +1 -1
  35. package/lib/resolvers/content-tree/references/Tweet.js +5 -4
  36. package/lib/resolvers/content-tree/references/Tweet.js.map +1 -1
  37. package/lib/resolvers/content.d.ts +5 -1
  38. package/lib/resolvers/content.js +7 -1
  39. package/lib/resolvers/content.js.map +1 -1
  40. package/lib/resolvers/image.d.ts +1 -0
  41. package/lib/resolvers/image.js +1 -0
  42. package/lib/resolvers/image.js.map +1 -1
  43. package/lib/resolvers/index.d.ts +9 -3
  44. package/lib/resolvers/richText.d.ts +1 -0
  45. package/lib/resolvers/teaser.d.ts +1 -2
  46. package/lib/resolvers/teaser.js +0 -1
  47. package/lib/resolvers/teaser.js.map +1 -1
  48. package/lib/resolvers/topper.d.ts +1 -0
  49. package/lib/resolvers/topper.js +1 -0
  50. package/lib/resolvers/topper.js.map +1 -1
  51. package/package.json +1 -1
  52. package/src/generated/index.ts +90 -17
  53. package/src/helpers/isError.ts +3 -0
  54. package/src/model/CapiResponse.ts +46 -4
  55. package/src/model/Concept.ts +23 -10
  56. package/src/model/Image.ts +1 -5
  57. package/src/model/RichText.test.ts +2 -0
  58. package/src/model/RichText.ts +3 -0
  59. package/src/model/Topper.ts +4 -0
  60. package/src/model/__snapshots__/RichText.test.ts.snap +33 -0
  61. package/src/model/schemas/capi/base-schema.ts +3 -0
  62. package/src/model/schemas/capi/content-package.ts +1 -0
  63. package/src/resolvers/content-tree/extractText.ts +21 -0
  64. package/src/resolvers/content-tree/references/Flourish.ts +48 -11
  65. package/src/resolvers/content-tree/references/RawImage.ts +1 -4
  66. package/src/resolvers/content-tree/references/Tweet.ts +1 -4
  67. package/src/resolvers/content.ts +10 -1
  68. package/src/resolvers/image.ts +1 -1
  69. package/src/resolvers/teaser.ts +0 -1
  70. package/src/resolvers/topper.ts +1 -0
  71. package/tsconfig.tsbuildinfo +1 -1
  72. package/typedefs/content.graphql +21 -7
  73. package/typedefs/image.graphql +10 -0
  74. package/typedefs/richText.graphql +1 -0
  75. package/typedefs/teaser.graphql +0 -1
  76. package/typedefs/topper.graphql +7 -0
@@ -0,0 +1,3 @@
1
+ export default function isError(error: unknown): error is Error {
2
+ return error instanceof Error
3
+ }
@@ -18,6 +18,7 @@ import sortBy from 'lodash.sortby'
18
18
 
19
19
  import { CAPIImage } from './Image'
20
20
  import { Concept } from './Concept'
21
+ import isError from '../helpers/isError'
21
22
  import { uuidFromUrl } from '../helpers/metadata'
22
23
  import { schemas } from './schemas/capi'
23
24
  import { AccessLevel, ContentType, PackageDesign } from '../resolvers/scalars'
@@ -195,8 +196,25 @@ export class CapiResponse {
195
196
  types() {
196
197
  return this.capiData.types
197
198
  }
198
- url() {
199
- return this.capiData.webUrl
199
+ async url(vanity?: boolean | null) {
200
+ const url = this.capiData.webUrl
201
+ if (vanity) {
202
+ try {
203
+ const vanityUrl = await this.context.dataSources.vanityUrls.get(url)
204
+ // IM: I don't _think_ vanityUrl will ever be nullish (instead
205
+ // returning the input URL if there is no vanity) but let's play it
206
+ // safe
207
+ return vanityUrl ?? url
208
+ } catch (error) {
209
+ if (isError(error)) {
210
+ logRecoverableError({
211
+ logger: this.context.logger,
212
+ error,
213
+ })
214
+ }
215
+ }
216
+ }
217
+ return url
200
218
  }
201
219
  leadImages() {
202
220
  if ('leadImages' in this.capiData) return this.capiData.leadImages
@@ -296,9 +314,10 @@ export class CapiResponse {
296
314
  return Boolean('standout' in this.capiData && this.capiData.standout?.scoop)
297
315
  }
298
316
 
299
- relativeUrl() {
317
+ async relativeUrl(vanity?: boolean | null) {
300
318
  const RELATIVE_URL_REGEX = /https?:\/\/www.ft.com/
301
- return this.url().replace(RELATIVE_URL_REGEX, '')
319
+ const url = await this.url(vanity)
320
+ return url.replace(RELATIVE_URL_REGEX, '')
302
321
  }
303
322
 
304
323
  type(validate?: true): LiteralUnionScalarValues<typeof ContentType>
@@ -359,6 +378,12 @@ export class CapiResponse {
359
378
  )
360
379
  }
361
380
 
381
+ getGenreConcept() {
382
+ return this.annotations().find((annotation: Concept) =>
383
+ annotation.isGenreType()
384
+ )
385
+ }
386
+
362
387
  getPodcastBrandConcept() {
363
388
  return this.annotations().find((annotation: Concept) =>
364
389
  annotation.isPodcastBrand()
@@ -421,6 +446,12 @@ export class CapiResponse {
421
446
  return null
422
447
  }
423
448
 
449
+ async tableOfContents() {
450
+ return 'tableOfContents' in this.capiData
451
+ ? this.capiData.tableOfContents
452
+ : null
453
+ }
454
+
424
455
  async liveBlogPosts(): Promise<CapiResponse[] | []> {
425
456
  const contains = await this.contains()
426
457
 
@@ -435,6 +466,17 @@ export class CapiResponse {
435
466
  return []
436
467
  }
437
468
 
469
+ containedIn() {
470
+ if (!('containedIn' in this.capiData)) {
471
+ return null
472
+ }
473
+ const containerId = this.capiData.containedIn?.[0]?.id
474
+ if (!containerId) {
475
+ return null
476
+ }
477
+ return this.context.dataSources.capi.getContent(uuidFromUrl(containerId))
478
+ }
479
+
438
480
  isContainedInPackage(): boolean {
439
481
  return 'containedIn' in this.capiData
440
482
  ? (this.capiData.containedIn?.length ?? 0) > 0
@@ -1,8 +1,9 @@
1
1
  import type { Annotation } from '../types/internal-content'
2
2
  import type { QueryContext } from '..'
3
- import type { DataSources } from '../datasources'
4
3
  import { uuidFromUrl } from '../helpers/metadata'
5
4
  import imageServiceUrl from '../helpers/imageService'
5
+ import isError from '../helpers/isError'
6
+ import { logRecoverableError } from '@dotcom-reliability-kit/log-error'
6
7
  import conceptIds from '@financial-times/n-concept-ids'
7
8
 
8
9
  const CAPI_ID_PREFIX = /^https?:\/\/(?:www|api)\.ft\.com\/things?\//
@@ -30,6 +31,7 @@ export const predicates = {
30
31
 
31
32
  export const types = {
32
33
  brand: 'http://www.ft.com/ontology/product/Brand',
34
+ genre: 'http://www.ft.com/ontology/Genre',
33
35
  }
34
36
 
35
37
  export const packageBrands = [
@@ -53,11 +55,9 @@ type Genre =
53
55
  | 'recipe'
54
56
 
55
57
  export class Concept {
56
- #dataSources: DataSources
57
58
  #systemCode: string
58
59
 
59
- constructor(private capiConcept: Annotation, context: QueryContext) {
60
- this.#dataSources = context.dataSources
60
+ constructor(private capiConcept: Annotation, private context: QueryContext) {
61
61
  this.#systemCode = context.systemCode ?? 'cp-content-pipeline'
62
62
  }
63
63
 
@@ -100,6 +100,13 @@ export class Concept {
100
100
  )
101
101
  }
102
102
 
103
+ isGenreType() {
104
+ return (
105
+ this.predicate() === predicates.isClassifiedBy &&
106
+ this.directType() === types.genre
107
+ )
108
+ }
109
+
103
110
  isPodcastBrand() {
104
111
  return (
105
112
  this.predicate() === predicates.implicitlyClassifiedBy &&
@@ -136,14 +143,18 @@ export class Concept {
136
143
 
137
144
  if (args?.vanity) {
138
145
  try {
139
- const vanity: string | null = await this.#dataSources.vanityUrls.get(
140
- url
141
- )
146
+ const vanity: string | null =
147
+ await this.context.dataSources.vanityUrls.get(url)
142
148
  if (vanity !== null) {
143
149
  url = vanity
144
150
  }
145
- } catch (err) {
146
- //TODO:AG:26-09-2022 log error here, but fail gracefully to use non-vanity
151
+ } catch (error) {
152
+ if (isError(error)) {
153
+ logRecoverableError({
154
+ logger: this.context.logger,
155
+ error,
156
+ })
157
+ }
147
158
  }
148
159
  }
149
160
 
@@ -159,7 +170,9 @@ export class Concept {
159
170
  }
160
171
 
161
172
  try {
162
- const peopleData = await this.#dataSources.capi.getPerson(this.uuid())
173
+ const peopleData = await this.context.dataSources.capi.getPerson(
174
+ this.uuid()
175
+ )
163
176
 
164
177
  if (!peopleData || !peopleData._imageUrl) return null
165
178
 
@@ -7,12 +7,12 @@ import type {
7
7
  } from '../types/internal-content'
8
8
  import type { QueryContext } from '..'
9
9
  import { logRecoverableError } from '@dotcom-reliability-kit/log-error/lib'
10
- import { ContentTree } from '@financial-times/content-tree'
11
10
  import {
12
11
  LiteralUnionScalarValues,
13
12
  validLiteralUnionValue,
14
13
  } from '../resolvers/literal-union'
15
14
  import { ImageFormat, ImageType } from '../resolvers/scalars'
15
+ import isError from '../helpers/isError'
16
16
 
17
17
  export type ImageSource = {
18
18
  url: string
@@ -25,10 +25,6 @@ export type ImageSourceArgs = {
25
25
  maxDpr?: number | null
26
26
  }
27
27
 
28
- function isError(error: unknown): error is Error {
29
- return error instanceof Error
30
- }
31
-
32
28
  export interface Image {
33
29
  type(): LiteralUnionScalarValues<typeof ImageType>
34
30
  caption(): string | null
@@ -17,6 +17,7 @@ describe('RichText resolver', () => {
17
17
  expect(await model.structured()).toMatchInlineSnapshot(`
18
18
  Object {
19
19
  "references": Array [],
20
+ "text": "",
20
21
  "tree": Object {
21
22
  "children": Array [
22
23
  Object {
@@ -36,6 +37,7 @@ describe('RichText resolver', () => {
36
37
  expect(await model.structured()).toMatchInlineSnapshot(`
37
38
  Object {
38
39
  "references": Array [],
40
+ "text": "",
39
41
  "tree": Object {
40
42
  "children": Array [],
41
43
  "type": "body",
@@ -1,4 +1,5 @@
1
1
  import bodyXMLToTree from '../resolvers/content-tree/bodyXMLToTree'
2
+ import extractText from '../resolvers/content-tree/extractText'
2
3
  import updateTreeWithReferenceIds from '../resolvers/content-tree/updateTreeWithReferenceIds'
3
4
  import { LiteralUnionScalarValues } from '../resolvers/literal-union'
4
5
  import { RichTextSource } from '../resolvers/scalars'
@@ -45,9 +46,11 @@ export class RichText {
45
46
  tree,
46
47
  this.contentApiData
47
48
  )
49
+ const text = extractText(tree)
48
50
 
49
51
  return {
50
52
  tree: treeWithReferences,
53
+ text,
51
54
  references,
52
55
  }
53
56
  }
@@ -274,6 +274,10 @@ export class Topper {
274
274
  return this.capiResponse.getBrandConcept() ?? null
275
275
  }
276
276
 
277
+ genreConcept() {
278
+ return this.capiResponse.getGenreConcept() ?? null
279
+ }
280
+
277
281
  design() {
278
282
  return this.capiResponse.design()
279
283
  }
@@ -97,6 +97,39 @@ Object {
97
97
  },
98
98
  },
99
99
  ],
100
+ "text": "Eliot After The Waste Land (Eliot Biographies, 2)by Robert Crawford, Jonathan Cape £25
101
+
102
+ After a six-year wait, Robert Crawford follows up Young Eliot (2016) with his second (and final) volume on the life of TS Eliot in time for the centenary of “The Waste Land”. Here, he explores Eliot’s marriage, religious life and draws on the 1,131 letters written to American friend Emily Hale that were only released from embargo in 2020 to probe the poet’s later years with tact and empathy.
103
+
104
+ All the Knowledge in the World: The Extraordinary History of the Encyclopaediaby Simon Garfield, Weidenfeld & Nicolson £18.99
105
+
106
+ Simon Garfield, author of quirky histories on everything from fonts to maps, surveys the publishing phenomenon that is the encyclopedia. Tracing its origins from Ancient Greece right up to its modern incarnation in Wikipedia, his handsome book offers an erudite and amusing exploration of the human quest for knowledge.
107
+
108
+ The Waste Land: A Biography of a Poemby Matthew Hollis, Faber £20
109
+
110
+ In the 100 years since TS Eliot penned his famous poem, it has taken on a life of its own. So it’s fitting, perhaps, that Matthew Hollis treats Eliot’s work to its own biography. This richly analytical book locates the poem’s genesis in the aftermath of the first world war and the “nightmare agony” of Eliot’s disastrous marriage.
111
+
112
+ Endless Flight: The Life of Joseph Rothby Keiron Pim, Granta £25
113
+
114
+ “I paint the portrait of the age,” Joseph Roth once claimed. Certainly, his work as a journalist from 1917-1939, dissecting central and eastern Europe in more than a thousand essays and anticipating the collapse of democracy on the continent as well as 19 novels provides an exceptional anatomy of a tumultuous period of history. Keiron Pim’s biography goes some way to introducing the great Austro-Hungarian writer to a new age.
115
+
116
+ Super-Infinite: The Transformations of John Donneby Katherine Rundell, Faber £16.99
117
+
118
+ Katherine Rundell, a Fellow of All Souls College, Oxford, has produced a remarkable life of John Donne, the great poet of love, sex and death. Winner of this year’s Baillie Gifford Prize, her biography of the man who was at once soldier, poet, prisoner and priest is sensitive and witty, capturing the essence of a tricky subject.
119
+
120
+ The Book of Phobias and Manias: A History of the World in 99 Obsessionsby Kate Summerscale, Profile £16.99
121
+
122
+ This neat compendium from the prizewinning author of The Suspicions of Mr Whicher charts a broad and intriguing range of fears and madness. Although these phobias and manias are alphabetised, Kate Summerscale suggests groupings (textures, animals, communal crazes) that — accompanied by a lightly erudite introduction — illuminate some of the darker corners of our collective psyche.
123
+
124
+ Papyrus: The Invention of Books in the Ancient Worldby Irene Vallejo, Hodder & Stoughton £25
125
+
126
+ Described as “a masterpiece” by Mario Vargas Llosa when it was first published in Spain in 2019, this bestselling phenomenon is now available in English. In it, Irene Vallejo recounts the birth of literary culture in the ancient world while interweaving dynamic, thrilling tales that underscore and celebrate the power of words to change the world.
127
+
128
+ Magnificent Rebels: The First Romantics and the Invention of the Selfby Andrea Wulf, John Murray £25/Knopf $35
129
+
130
+ Between 1794-1806, the cream of Germany’s intelligentsia descended on the tiny university town of Jena. There, Johann Wolfgang von Goethe mingled with philosophers Friedrich Schelling and Georg Wilhelm Friedrich Hegel, the scientist-explorer Alexander von Humboldt and the playwright Friedrich Schiller during the first flush of Romanticism. Andrea Wulf’s wonderful book brings to life the “Jena set” and a golden age of German culture.
131
+
132
+ Join our online book group on Facebook at FT Books Café",
100
133
  "tree": Object {
101
134
  "children": Array [
102
135
  Object {
@@ -145,6 +145,9 @@ export const baseMetadataSchema = z.object({
145
145
  .object({ apiUrl: z.string(), id: z.string() })
146
146
  .array()
147
147
  .optional(),
148
+ tableOfContents: z
149
+ .object({ labelType: z.string(), sequence: z.string() })
150
+ .optional(),
148
151
  })
149
152
 
150
153
  export const baseContentSchema = z.object({
@@ -9,6 +9,7 @@ const contentPackageContentSchema = baseContentSchema.pick({
9
9
  summary: true,
10
10
  alternativeTitles: true,
11
11
  contains: true,
12
+ tableOfContents: true,
12
13
  design: true,
13
14
  bodyXML: true,
14
15
  })
@@ -0,0 +1,21 @@
1
+ import { ContentTree } from '@financial-times/content-tree'
2
+
3
+ const extractTextFromNode = (
4
+ node: ContentTree.Paragraph | ContentTree.Phrasing
5
+ ): string => {
6
+ if (node.type === 'text') {
7
+ return node.value
8
+ } else if ('children' in node) {
9
+ return node.children.map(extractTextFromNode).join('')
10
+ } else {
11
+ return ''
12
+ }
13
+ }
14
+
15
+ export default function extractText(tree: ContentTree.Body): string {
16
+ return tree.children
17
+ .filter((node): node is ContentTree.Paragraph => node.type === 'paragraph')
18
+ .map(extractTextFromNode)
19
+ .map((paragraphText) => paragraphText.trim())
20
+ .join('\n\n')
21
+ }
@@ -1,11 +1,11 @@
1
1
  import imageServiceUrl from '../../../helpers/imageService'
2
2
  import { FlourishResolvers } from '../../../generated'
3
+ import { QueryContext } from '../../..'
4
+ import isError from '../../../helpers/isError'
5
+ import { logRecoverableError } from '@dotcom-reliability-kit/log-error/lib'
3
6
 
4
7
  export const Flourish = {
5
8
  async fallbackImage(parent, _args, context) {
6
- const DEFAULT_WIDTH = 2626
7
- const DEFAULT_HEIGHT = 1459
8
-
9
9
  const type = parent.reference.flourishType
10
10
  const timestamp =
11
11
  typeof parent.reference.timestamp === 'string'
@@ -15,23 +15,60 @@ export const Flourish = {
15
15
  const flourishUrl = `https://public.flourish.studio/${type}/${
16
16
  parent.reference.id
17
17
  }/thumbnail${timestamp ? '?cacheBuster=' + timestamp : ''}`
18
- const imageMetadata = await context.dataSources.origami.getImageMetadata(
19
- flourishUrl
20
- )
18
+
19
+ const imageMetadata = await getImageMetadata(context, flourishUrl)
20
+
21
+ const width = imageMetadata.width
22
+ const height = imageMetadata.height
21
23
 
22
24
  const imageServiceWrappedUrl = imageServiceUrl({
23
25
  url: flourishUrl,
24
26
  systemCode: context.systemCode ?? 'cp-content-pipeline',
25
- width: imageMetadata?.width || DEFAULT_WIDTH,
27
+ width,
26
28
  })
27
29
 
28
30
  return {
29
- type: 'image',
30
31
  url: imageServiceWrappedUrl,
31
- sourceSet: [],
32
+ type: 'image',
32
33
  format: 'standard',
33
- width: imageMetadata?.width ? imageMetadata?.width : DEFAULT_WIDTH,
34
- height: imageMetadata?.height ? imageMetadata?.height : DEFAULT_HEIGHT,
34
+ sourceSet: [],
35
+ width,
36
+ height,
35
37
  }
36
38
  },
37
39
  } satisfies FlourishResolvers
40
+
41
+ type ImageMetadata = {
42
+ width: number
43
+ height: number
44
+ }
45
+
46
+ const getImageMetadata = async (
47
+ context: QueryContext,
48
+ flourishUrl: string
49
+ ): Promise<ImageMetadata> => {
50
+ const DEFAULT_WIDTH = 2626
51
+ const DEFAULT_HEIGHT = 1459
52
+
53
+ try {
54
+ const imageMetadata = await context.dataSources.origami.getImageMetadata(
55
+ flourishUrl
56
+ )
57
+
58
+ return {
59
+ width: imageMetadata?.width || DEFAULT_WIDTH,
60
+ height: imageMetadata?.height || DEFAULT_HEIGHT,
61
+ }
62
+ } catch (error) {
63
+ if (isError(error)) {
64
+ logRecoverableError({
65
+ error,
66
+ logger: context.logger,
67
+ })
68
+ }
69
+ return {
70
+ width: DEFAULT_WIDTH,
71
+ height: DEFAULT_HEIGHT,
72
+ }
73
+ }
74
+ }
@@ -4,10 +4,7 @@ import { logRecoverableError } from '@dotcom-reliability-kit/log-error/lib'
4
4
  import imageServiceUrl from '../../../helpers/imageService'
5
5
  import { RawImage as RawImageNode } from '../Workarounds'
6
6
  import { RawImageResolvers } from '../../../generated'
7
-
8
- function isError(error: unknown): error is Error {
9
- return error instanceof Error
10
- }
7
+ import isError from '../../../helpers/isError'
11
8
 
12
9
  class RawImageModel implements Image {
13
10
  constructor(private rawImage: RawImageNode, private context: QueryContext) {}
@@ -1,9 +1,6 @@
1
1
  import { logRecoverableError } from '@dotcom-reliability-kit/log-error'
2
2
  import { TweetResolvers } from '../../../generated'
3
-
4
- function isError(error: unknown): error is Error {
5
- return error instanceof Error
6
- }
3
+ import isError from '../../../helpers/isError'
7
4
 
8
5
  export const Tweet = {
9
6
  async html(parent, _args, context) {
@@ -1,6 +1,7 @@
1
1
  import { Topper } from '../model/Topper'
2
2
  import { Byline } from '../model/Byline'
3
3
  import {
4
+ ArticleResolvers,
4
5
  AudioResolvers,
5
6
  ContentPackageResolvers,
6
7
  ContentResolvers,
@@ -33,17 +34,24 @@ const resolvers = {
33
34
  return new Byline(bylineText, authorUrlMapping).buildBylineTree()
34
35
  },
35
36
  url: (parent, args) =>
36
- args.relative ? parent.relativeUrl() : parent.url(),
37
+ args.relative
38
+ ? parent.relativeUrl(args?.vanity)
39
+ : parent.url(args?.vanity),
37
40
  type: (parent) => parent.type(),
38
41
  mainImage: (parent) => parent.mainImage(),
39
42
  altTitle: (parent) => parent.alternativeTitle(),
40
43
  altStandfirst: (parent) => parent.alternativeStandfirst(),
41
44
  publishedDate: (parent) => parent.publishedDate(),
42
45
  annotations: (parent) => parent.annotations(),
46
+ accessLevel: (parent) => parent.accessLevel(),
43
47
  commentsEnabled: (parent) => parent.commentsEnabled(),
44
48
  design: (parent) => parent.design(),
45
49
  },
46
50
 
51
+ Article: {
52
+ containedIn: (parent) => parent.containedIn(),
53
+ },
54
+
47
55
  LiveBlogPackage: {
48
56
  liveBlogPosts: (parent) => parent.liveBlogPosts(),
49
57
  realtime: (parent) => parent.realtime(),
@@ -60,6 +68,7 @@ const resolvers = {
60
68
  null,
61
69
  },
62
70
  } satisfies {
71
+ Article: ArticleResolvers
63
72
  Content: ContentResolvers
64
73
  LiveBlogPackage: LiveBlogPackageResolvers
65
74
  ContentPackage: ContentPackageResolvers
@@ -1,4 +1,3 @@
1
- import { ContentTree } from '@financial-times/content-tree'
2
1
  import { ImageResolvers } from '../generated'
3
2
  import { LiteralUnionScalarValues } from './literal-union'
4
3
  import { ImageFormat } from './scalars'
@@ -60,6 +59,7 @@ const resolvers = {
60
59
  width: async (image) => (await image.dimensions())?.width ?? null,
61
60
  height: async (image) => (await image.dimensions())?.height ?? null,
62
61
  sourceSet: (image, args) => image.sourceSet(args),
62
+ altText: (image) => image.alt(),
63
63
  },
64
64
  } satisfies { Image: ImageResolvers }
65
65
 
@@ -25,7 +25,6 @@ const resolvers = {
25
25
  isScoop: parent.isScoop(),
26
26
  }
27
27
  },
28
- theme: (parent) => parent.design()?.theme,
29
28
  standfirst: (parent) => parent.standfirst(),
30
29
  },
31
30
  } satisfies { Teaser: TeaserResolvers }
@@ -30,6 +30,7 @@ const resolvers = {
30
30
 
31
31
  TopperWithBrand: {
32
32
  brandConcept: (topper) => topper.brandConcept(),
33
+ genreConcept: (topper) => topper.genreConcept(),
33
34
  },
34
35
 
35
36
  PodcastTopper: {