diff --git a/src/app/components/LinkedData/index.test.tsx b/src/app/components/LinkedData/index.test.tsx index 469ea758784..b9116e8c977 100644 --- a/src/app/components/LinkedData/index.test.tsx +++ b/src/app/components/LinkedData/index.test.tsx @@ -103,6 +103,44 @@ describe('LinkedData', () => { ], }; + describe('SpeakableSpecification schema', () => { + const baseProps = { + type: 'WebPage', + seoTitle: 'Hindi Most Read Title', + datePublished: '2024-01-01T12:00:00.000Z', + dateModified: '2024-01-01T13:00:00.000Z', + }; + + it('includes SpeakableSpecification for Hindi service with title only', () => { + render( + + + , + ); + const output = getLinkedDataOutput(); + const speakable = output['@graph'].find( + (item: Record) => item.speakable, + ); + expect(speakable).toBeTruthy(); + expect(speakable.speakable).toEqual([ + { '@type': 'SpeakableSpecification', xpath: ['/html/head/title'] }, + ]); + }); + + it('does not include SpeakableSpecification for non-enabled service', () => { + render( + + + , + ); + const output = getLinkedDataOutput(); + const speakable = output['@graph'].find( + (item: Record) => item.speakable, + ); + expect(speakable).toBeUndefined(); + }); + }); + it('should correctly render linked data for Ondemand Radio page', () => { render( diff --git a/src/app/components/LinkedData/index.tsx b/src/app/components/LinkedData/index.tsx index 2625928b4bc..c61463a880b 100644 --- a/src/app/components/LinkedData/index.tsx +++ b/src/app/components/LinkedData/index.tsx @@ -3,6 +3,7 @@ import { Helmet } from 'react-helmet'; import { RequestContext } from '#contexts/RequestContext'; import serialiseForScript from '#lib/utilities/serialiseForScript'; import getBrandedImage from '#lib/utilities/getBrandedImage'; +import { Services } from '#app/models/types/global'; import { ServiceContext } from '../../contexts/ServiceContext'; import getAboutTagsContent from './getAboutTagsContent'; import { BylineLinkedData, LinkedDataProps } from './types'; @@ -28,6 +29,34 @@ type AuthorStructure = { type Author = AuthorStructure | AuthorStructure[]; +type SpeakableSpecification = { + '@type': 'SpeakableSpecification'; + xpath: string[]; +}; + +const SPEAKABLE_ENABLED_SERVICES = ['hindi']; // TODO: to be extended +const SUPPORTED_SPEAKABLE_TYPES = ['WebPage']; + +const getSpeakableXpaths = ({ + service, + seoTitle, + type, +}: { + service: Services; + seoTitle?: string; + type: string; +}): SpeakableSpecification[] | null => { + if (!SUPPORTED_SPEAKABLE_TYPES.includes(type)) return null; + if (!SPEAKABLE_ENABLED_SERVICES.includes(service)) return null; + if (!seoTitle) return null; + return [ + { + '@type': 'SpeakableSpecification', + xpath: ['/html/head/title'], + }, + ]; +}; + const LinkedData = ({ showAuthor = false, type, @@ -177,6 +206,13 @@ const LinkedData = ({ if (hasByline && bylineAuthors && bylineAuthors.length > 0) { author = bylineAuthors.length === 1 ? bylineAuthors[0] : bylineAuthors; } + + const speakableXpaths = getSpeakableXpaths({ + service, + seoTitle, + type, + }); + const linkedData = { '@type': type, url: canonicalNonUkLink, @@ -191,10 +227,9 @@ const LinkedData = ({ coverageEndTime, inLanguage, ...(aboutTags && { about: getAboutTagsContent(aboutTags) }), - ...(showAuthor && { - author, - }), + ...(showAuthor && { author }), ...(hasByline && places.length > 0 && { locationCreated }), + ...(speakableXpaths && { speakable: speakableXpaths }), }; return (