diff --git a/dotcom-rendering/scripts/json-schema/check-schema.js b/dotcom-rendering/scripts/json-schema/check-schema.js index 77c69f74e45..5c64c22f360 100644 --- a/dotcom-rendering/scripts/json-schema/check-schema.js +++ b/dotcom-rendering/scripts/json-schema/check-schema.js @@ -1,14 +1,16 @@ +/* eslint-disable no-console -- logs are useful in scripts */ const fs = require('fs'); const path = require('path'); - -const root = path.resolve(__dirname, '..', '..'); const { getArticleSchema, getFrontSchema, getNewsletterPageSchema, + getTagFrontSchema, getBlockSchema, } = require('./get-schema'); +const root = path.resolve(__dirname, '..', '..'); + const existingArticleSchema = fs.readFileSync( `${root}/src/model/article-schema.json`, { encoding: 'utf-8' }, @@ -17,6 +19,10 @@ const existingFrontSchema = fs.readFileSync( `${root}/src/model/front-schema.json`, { encoding: 'utf-8' }, ); +const existingTagFrontSchema = fs.readFileSync( + `${root}/src/model/tag-front-schema.json`, + { encoding: 'utf-8' }, +); const existingNewsletterSchema = fs.readFileSync( `${root}/src/model/newsletter-page-schema.json`, { encoding: 'utf-8' }, @@ -28,12 +34,14 @@ const existingBlockSchema = fs.readFileSync( const articleSchema = getArticleSchema(); const frontSchema = getFrontSchema(); +const tagFrontSchema = getTagFrontSchema(); const newsletterSchema = getNewsletterPageSchema(); const blockSchema = getBlockSchema(); if ( existingArticleSchema !== articleSchema || existingFrontSchema !== frontSchema || + existingTagFrontSchema !== tagFrontSchema || existingNewsletterSchema !== newsletterSchema || existingBlockSchema !== blockSchema ) { diff --git a/dotcom-rendering/scripts/json-schema/gen-schema.js b/dotcom-rendering/scripts/json-schema/gen-schema.js index b7816753c21..7daaaaeeb6a 100644 --- a/dotcom-rendering/scripts/json-schema/gen-schema.js +++ b/dotcom-rendering/scripts/json-schema/gen-schema.js @@ -1,16 +1,19 @@ -const path = require('path'); - -const root = path.resolve(__dirname, '..', '..'); +/* eslint-disable no-console -- Logs are useful for scripts */ const fs = require('fs'); +const path = require('path'); const { getArticleSchema, getFrontSchema, getNewsletterPageSchema, + getTagFrontSchema, getBlockSchema, } = require('./get-schema'); +const root = path.resolve(__dirname, '..', '..'); + const articleSchema = getArticleSchema(); const frontSchema = getFrontSchema(); +const tagFrontSchema = getTagFrontSchema(); const newsletterPageSchema = getNewsletterPageSchema(); const blockSchema = getBlockSchema(); @@ -20,7 +23,6 @@ fs.writeFile( 'utf8', (err) => { if (err) { - // eslint-disable-next-line @typescript-eslint/tslint/config console.log(err); } }, @@ -32,7 +34,17 @@ fs.writeFile( 'utf8', (err) => { if (err) { - // eslint-disable-next-line @typescript-eslint/tslint/config + console.log(err); + } + }, +); + +fs.writeFile( + `${root}/src/model/tag-front-schema.json`, + tagFrontSchema, + 'utf8', + (err) => { + if (err) { console.log(err); } }, @@ -44,7 +56,6 @@ fs.writeFile( 'utf8', (err) => { if (err) { - // eslint-disable-next-line @typescript-eslint/tslint/config console.log(err); } }, @@ -56,7 +67,6 @@ fs.writeFile( 'utf8', (err) => { if (err) { - // eslint-disable-next-line @typescript-eslint/tslint/config console.log(err); } }, diff --git a/dotcom-rendering/scripts/json-schema/get-schema.js b/dotcom-rendering/scripts/json-schema/get-schema.js index d0bec778827..6047c72251e 100644 --- a/dotcom-rendering/scripts/json-schema/get-schema.js +++ b/dotcom-rendering/scripts/json-schema/get-schema.js @@ -7,6 +7,7 @@ const program = TJS.getProgramFromFiles( [ path.resolve(`${root}/index.d.ts`), path.resolve(`${root}/src/types/frontend.ts`), + path.resolve(`${root}/src/types/tagFront.ts`), path.resolve(`${root}/src/types/newslettersPage.ts`), ], { @@ -31,6 +32,13 @@ module.exports = { 4, ); }, + getTagFrontSchema: () => { + return JSON.stringify( + TJS.generateSchema(program, 'FETagFrontType', settings), + null, + 4, + ); + }, getNewsletterPageSchema: () => { return JSON.stringify( TJS.generateSchema(program, 'FENewslettersPageType', settings), diff --git a/dotcom-rendering/src/components/DecideContainerByTrails.tsx b/dotcom-rendering/src/components/DecideContainerByTrails.tsx new file mode 100644 index 00000000000..12a3cb1cd78 --- /dev/null +++ b/dotcom-rendering/src/components/DecideContainerByTrails.tsx @@ -0,0 +1,281 @@ +import { + Card100Media50, + Card25Media25, + Card33Media33, + Card50Media50, + CardDefault, +} from '../lib/cardWrappers'; +import type { DCRFrontCard } from '../types/front'; +import { LI } from './Card/components/LI'; +import { UL } from './Card/components/UL'; + +type Props = { + trails: DCRFrontCard[]; + speed: 'fast' | 'slow'; +}; + +export const OneCardFast = ({ trail }: { trail: DCRFrontCard }) => { + return ( + + ); +}; + +export const TwoCardFast = ({ trails }: { trails: DCRFrontCard[] }) => { + if (!trails[0] || !trails[1]) return <>; + return ( + + ); +}; + +export const ThreeCardFast = ({ trails }: { trails: DCRFrontCard[] }) => { + if (!trails[0] || !trails[1] || !trails[2]) return <>; + return ( + + ); +}; + +export const FourCardFast = ({ trails }: { trails: DCRFrontCard[] }) => { + if (!trails[0] || !trails[1] || !trails[2] || !trails[3]) return <>; + return ( + + ); +}; + +export const FiveCardFast = ({ trails }: { trails: DCRFrontCard[] }) => { + if (!trails[0] || !trails[1] || !trails[2] || !trails[3] || !trails[4]) + return <>; + return ( + + ); +}; + +export const SixCardFast = ({ trails }: { trails: DCRFrontCard[] }) => { + if ( + !trails[0] || + !trails[1] || + !trails[2] || + !trails[3] || + !trails[4] || + !trails[5] + ) + return <>; + + return ( + <> + + + + ); +}; +export const SevenCardFast = ({ trails }: { trails: DCRFrontCard[] }) => { + if ( + !trails[0] || + !trails[1] || + !trails[2] || + !trails[3] || + !trails[4] || + !trails[5] || + !trails[6] + ) + return <>; + + return ( + <> + + + + ); +}; + +export const EightCardFast = ({ trails }: { trails: DCRFrontCard[] }) => { + if ( + !trails[0] || + !trails[1] || + !trails[2] || + !trails[3] || + !trails[4] || + !trails[5] || + !trails[6] || + !trails[7] + ) + return <>; + + return ( + <> + + + + ); +}; + +export const BeyondEight = ({ trails }: { trails: DCRFrontCard[] }) => { + const afterEight = trails.slice(8); + + return ( + <> + ; + + + ); +}; + +export const DecideContainerByTrails = ({ trails }: Props) => { + // TODO: Respect 'speed' + + switch (trails.length) { + case 1: + return trails[0] !== undefined ? ( + + ) : null; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + case 6: + return ; + case 7: + return ; + case 8: + return ; + default: + return ; + } +}; diff --git a/dotcom-rendering/src/components/FrontPagination.stories.tsx b/dotcom-rendering/src/components/FrontPagination.stories.tsx new file mode 100644 index 00000000000..5bff9e01a6c --- /dev/null +++ b/dotcom-rendering/src/components/FrontPagination.stories.tsx @@ -0,0 +1,85 @@ +import { FrontPagination } from './FrontPagination'; + +export default { + component: FrontPagination, + title: 'Components/FrontPagination', +}; + +export const tagFrontHeader = () => { + return ( + <> +

1st page, 10 total pages

+ +

2nd page, 10 total pages

+ +

3rd page, 10 total pages

+ +

5th page, 10 total pages

+ +

8th page, 10 total pages

+ +

9th page, 10 total pages

+ +

10th page, 10 total pages

+ +

1st page, 2 total pages

+ +

2nd page, 2 total pages

+ + + ); +}; diff --git a/dotcom-rendering/src/components/FrontPagination.tsx b/dotcom-rendering/src/components/FrontPagination.tsx new file mode 100644 index 00000000000..01a1aae5111 --- /dev/null +++ b/dotcom-rendering/src/components/FrontPagination.tsx @@ -0,0 +1,186 @@ +import { css } from '@emotion/react'; +import { palette, space, textSans } from '@guardian/source-foundations'; +import { + SvgChevronLeftSingle, + SvgChevronRightSingle, +} from '@guardian/source-react-components'; +import { Fragment } from 'react'; +import { formatCount } from '../lib/formatCount'; + +type Props = { + pageId: string; + sectionName: string; + totalContent: number; + currentPage: number; + lastPage: number; +}; + +const paginationWrapperCss = css` + margin-top: ${space[6]}px; + + padding-top: ${space[6]}px; + + display: flex; + flex-direction: row; + justify-content: space-between; + + border-top: 1.5px solid ${palette.neutral[86]}; +`; + +const paginationLegendCss = css` + ${textSans.xsmall()} +`; + +const paginationItemContainerCss = css` + display: flex; + flex-direction: row; + align-items: center; +`; + +const paginationItemCss = css` + display: flex; + flex-direction: row; + justify-content: center; + + min-width: 25px; + padding: 3px ${space[1]}px; + margin: ${space[1]}px; + + ${textSans.xxsmall({ fontWeight: 'bold' })} + text-decoration: none; + + color: ${palette.neutral[46]}; + border-radius: 12px; + + border: 1px solid transparent; + + &:hover { + border: 1px solid ${palette.neutral[86]}; + } +`; + +const activePaginationItemCss = css` + color: ${palette.neutral[100]}; + background-color: ${palette.neutral[46]}; + + &:hover { + border: 1px solid transparent; + } +`; + +const paginationDotsCss = css` + ${textSans.xxsmall({ fontWeight: 'bold' })} + color: ${palette.neutral[46]}; + padding: ${space[1]}px; +`; + +const paginationArrowsCss = css` + width: 25px; + height: 25px; + border: 1px solid ${palette.neutral[86]}; + + border-radius: 20px; + + padding: 2px; + + svg { + color: ${palette.neutral[86]}; + } +`; + +const getPageRange = (currentPage: number, lastPage: number) => { + // Safety function to check we're within bounds + const isValid = (item: number) => item > 0 && item <= lastPage; + + if (currentPage === 1 || currentPage === 2 || currentPage === 3) { + return [1, 2, 3, 4].filter(isValid); + } else if (currentPage + 2 >= lastPage) { + return [1, lastPage - 2, lastPage - 1, lastPage].filter(isValid); + } else { + return [1, currentPage - 1, currentPage, currentPage + 1].filter( + isValid, + ); + } +}; + +const getLink = (pageId: string, pageNo: number) => { + if (pageNo === 1) return `/${pageId}`; + return `/${pageId}?page=${pageNo}`; +}; + +export const FrontPagination = ({ + pageId, + totalContent, + sectionName, + currentPage, + lastPage, +}: Props) => { + const pageRange = getPageRange(currentPage, lastPage); + + return ( +
+ + About {formatCount(totalContent).long} results for {sectionName} + +
+ {currentPage > 1 && ( + + + + )} + {pageRange.map((page, index) => { + // If there is a gap of more than 1 number between this entry and the last one + const shouldPrefixDots = + page > 1 && pageRange[index - 1] !== page - 1; + + // If this is the last item and we're not at the last page yet + const shouldSuffixDots = + index === pageRange.length - 1 && page !== lastPage; + + return ( + + {shouldPrefixDots && ( + ... + )} + {page === currentPage ? ( + + {page} + + ) : ( + + {page} + + )} + {shouldSuffixDots && ( + ... + )} + + ); + })} + {currentPage < lastPage && ( + + + + )} +
+
+ ); +}; diff --git a/dotcom-rendering/src/components/FrontSection.tsx b/dotcom-rendering/src/components/FrontSection.tsx index aeb37063fbd..958bb5d6d4d 100644 --- a/dotcom-rendering/src/components/FrontSection.tsx +++ b/dotcom-rendering/src/components/FrontSection.tsx @@ -13,10 +13,12 @@ import { decideContainerOverrides } from '../lib/decideContainerOverrides'; import type { EditionId } from '../lib/edition'; import type { DCRBadgeType } from '../types/badge'; import type { DCRContainerPalette, TreatType } from '../types/front'; +import type { DCRFrontPagination } from '../types/tagFront'; import { isAustralianTerritory, type Territory } from '../types/territory'; import { AustralianTerritorySwitcher } from './AustralianTerritorySwitcher.importable'; import { Badge } from './Badge'; import { ContainerTitle } from './ContainerTitle'; +import { FrontPagination } from './FrontPagination'; import { Island } from './Island'; import { ShowHideButton } from './ShowHideButton'; import { ShowMore } from './ShowMore.importable'; @@ -67,6 +69,9 @@ type Props = { /** Enable the "Show More" button on this container to allow readers to load more cards */ canShowMore?: boolean; ajaxUrl?: string; + /** Puts pagination at the bottom of the container allowing the user to navigate to other pages, + * usually used on the last container on a page */ + pagination?: DCRFrontPagination; /** Does this front section reside on a "paid for" content front */ isOnPaidContentFront?: boolean; /** Denotes the position of this section on the front */ @@ -112,8 +117,8 @@ const containerStyles = css` grid-template-rows: [headline-start show-hide-start] auto [show-hide-end headline-end content-toggleable-start content-start] auto - [content-end content-toggleable-end show-more-start] auto - [show-more-end]; + [content-end content-toggleable-end bottom-content-start] auto + [bottom-content-end]; grid-template-columns: [decoration-start] @@ -159,8 +164,8 @@ const containerStyles = css` [headline-start show-hide-start content-start] auto [show-hide-end content-toggleable-start] auto [headline-end treats-start] auto - [content-end content-toggleable-end treats-end show-more-start] auto - [show-more-end]; + [content-end content-toggleable-end treats-end bottom-content-start] auto + [bottom-content-end]; grid-template-columns: minmax(0, 1fr) @@ -179,8 +184,8 @@ const containerStyles = css` [headline-start content-start content-toggleable-start show-hide-start] auto [show-hide-end] auto [headline-end treats-start] auto - [content-end content-toggleable-end treats-end show-more-start] auto - [show-more-end]; + [content-end content-toggleable-end treats-end bottom-content-start] auto + [bottom-content-end]; grid-template-columns: minmax(0, 1fr) @@ -248,8 +253,8 @@ const sectionContentPadded = css` } `; -const sectionShowMore = css` - grid-row: show-more; +const sectionBottomContent = css` + grid-row: bottom-content; grid-column: content; `; @@ -407,6 +412,7 @@ export const FrontSection = ({ badge, canShowMore, ajaxUrl, + pagination, isOnPaidContentFront, index, targetedTerritory, @@ -554,7 +560,13 @@ export const FrontSection = ({ {children} -
+
{isString(targetedTerritory) && isAustralianTerritory(targetedTerritory) ? ( @@ -576,6 +588,15 @@ export const FrontSection = ({ /> ) : null} + {pagination && ( + + )}
{treats && ( diff --git a/dotcom-rendering/src/components/Picture.tsx b/dotcom-rendering/src/components/Picture.tsx index 6d38ae5e45e..addb322a980 100644 --- a/dotcom-rendering/src/components/Picture.tsx +++ b/dotcom-rendering/src/components/Picture.tsx @@ -273,6 +273,29 @@ export const getFallbackSource = (sources: ImageSource[]): ImageSource => { return fallback; }; +export const Sources = ({ sources }: { sources: ImageSource[] }) => { + return ( + <> + {sources.map((source) => { + return ( + + {/* High resolution (HDPI) sources*/} + + {/* Low resolution (MDPI) source*/} + + + ); + })} + + ); +}; + export const Picture = ({ role, format, @@ -332,23 +355,7 @@ export const Picture = ({ /> )} - {sources.map((source) => { - return ( - - {/* High resolution (HDPI) sources*/} - - {/* Low resolution (MDPI) source*/} - - - ); - })} - + {alt} { + return ( + <> +

Only a title

+ +

A title & a description

+ +

A title & a description with links

+ And a much longer description with lots of text, other thoughts and musings and a link

`} + /> +

A title & a description & an image

+ And a much longer description with lots of text, other thoughts and musings and a link

`} + image={ + 'https://uploads.guim.co.uk/2023/02/17/Josh_Halliday.jpg' + } + /> + + ); +}; diff --git a/dotcom-rendering/src/components/TagFrontHeader.tsx b/dotcom-rendering/src/components/TagFrontHeader.tsx new file mode 100644 index 00000000000..2fae5eb3e1c --- /dev/null +++ b/dotcom-rendering/src/components/TagFrontHeader.tsx @@ -0,0 +1,324 @@ +import { css, jsx } from '@emotion/react'; +import { + breakpoints, + from, + headline, + palette, + space, + until, +} from '@guardian/source-foundations'; +import { Fragment, type ReactNode } from 'react'; +import { decideContainerOverrides } from '../lib/decideContainerOverrides'; +import { isElement, parseHtml } from '../lib/domUtils'; +import { logger } from '../server/lib/logging'; +import type { DCRContainerPalette } from '../types/front'; +import { ContainerTitle } from './ContainerTitle'; +import { generateSources, Sources } from './Picture'; + +type Props = { + title: string; + description?: string; + image?: string; + containerPalette?: DCRContainerPalette; +}; + +const width = (columns: number, columnWidth: number, columnGap: number) => + `width: ${columns * columnWidth + (columns - 1) * columnGap}px;`; + +/** Not all browsers support CSS grid, so we set explicit width as a fallback */ +const fallbackStyles = css` + @supports not (display: grid) { + padding: 0 12px; + margin: 0 auto; + + ${from.mobileLandscape} { + padding: 0 20px; + } + + ${from.tablet} { + ${width(12, 40, 20)} + } + + ${from.desktop} { + ${width(12, 60, 20)} + } + + ${from.leftCol} { + ${width(14, 60, 20)} + } + + ${from.wide} { + ${width(16, 60, 20)} + } + } +`; + +const containerStyles = css` + display: grid; + + grid-template-rows: + [headline-start image-start] auto + [headline-end content-start] auto + [content-end image-end] auto; + + grid-template-columns: + [viewport-start] 0px + [content-start title-start] + repeat(3, minmax(0, 1fr)) + [content-end title-end image-start] + minmax(0, 1fr) + [image-end] + 0px [viewport-end]; + + grid-auto-flow: dense; + column-gap: 10px; + + ${from.mobileLandscape} { + column-gap: 20px; + } + + ${from.tablet} { + grid-template-columns: + [viewport-start] minmax(0, 1fr) + [content-start title-start] + repeat(6, 40px) [content-end title-end] + repeat(4, 40px) + [image-start] + repeat(2, 40px) + [image-end] + minmax(0, 1fr) [viewport-end]; + } + + ${from.desktop} { + grid-template-columns: + [viewport-start] minmax(0, 1fr) + [content-start title-start] + repeat(6, 60px) [content-end] + repeat(4, 60px) + [title-end image-start] + repeat(2, 60px) + [image-end] + minmax(0, 1fr) [viewport-end]; + } + + ${from.leftCol} { + grid-template-rows: + [headline-start content-start image-start] auto + [headline-end content-end image-end] auto; + + grid-template-columns: + [viewport-start] minmax(0, 1fr) + [title-start] + repeat(2, 60px) + [title-end content-start] + repeat(7, 60px) [content-end] + repeat(3, 60px) + [image-start] + repeat(2, 60px) + [image-end] + minmax(0, 1fr) [viewport-end]; + } + + ${from.wide} { + grid-template-columns: + [viewport-start] minmax(0, 1fr) + [title-start] + repeat(3, 60px) + [title-end content-start] + repeat(8, 60px) [content-end] + repeat(2, 60px) + [image-start] + repeat(2, 60px) + [image-end] + repeat(1, 60px) + minmax(0, 1fr) [viewport-end]; + } +`; + +const sectionHeadline = css` + grid-row: headline; + grid-column: title; + + display: flex; + flex-direction: column; + + ${from.leftCol} { + position: relative; + ::after { + content: ''; + display: block; + width: 1px; + top: 0; + height: 1.875rem; + right: -10px; + position: absolute; + background-color: ${palette.neutral[86]}; + } + } +`; + +const paddings = css` + padding-top: ${space[2]}px; + padding-bottom: ${space[2]}px; +`; + +const sectionImage = css` + grid-row: image; + grid-column: image; + justify-self: end; +`; + +const sectionContent = css` + margin: 0; + + grid-column: content; + grid-row: content; +`; + +/** element which contains border and inner background colour, if set */ +const decoration = css` + grid-row: 1 / -1; + grid-column: 1 / -1; + + ${from.tablet} { + grid-column: 2 / -2; + } + + border-width: 1px; + border-color: ${palette.neutral[86]}; + border-style: none; +`; + +/** only visible once content stops sticking to left and right edges */ +const sideBorders = css` + ${from.tablet} { + margin: 0 -20px; + border-left-style: solid; + border-right-style: solid; + } +`; + +const titleStyle = css` + ${until.leftCol} { + max-width: 74%; + } +`; + +const paragraphStyle = css` + ${headline.xxxsmall()} + color: ${palette.neutral[46]}; +`; + +const buildElementTree = (node: Node): ReactNode => { + if (isElement(node)) { + switch (node.nodeName) { + case 'A': + return jsx('a', { + href: node.attributes.getNamedItem('href')?.value, + target: node.attributes.getNamedItem('target')?.value, + children: Array.from(node.childNodes).map(buildElementTree), + }); + case 'BR': + return jsx('br'); + default: + return jsx(node.tagName.toLowerCase(), { + children: Array.from(node.childNodes).map(buildElementTree), + }); + } + } else if (node.nodeType === node.TEXT_NODE) { + return node.textContent; + } else { + logger.warn('TagFrontHeader: Unknown element received', { + isDev: process.env.NODE_ENV !== 'production', + element: { + name: node.nodeName, + html: isElement(node) ? node.outerHTML : undefined, + }, + }); + return null; + } +}; + +const imageStyle = css` + width: 80px; + border-radius: 40px; + + ${from.desktop} { + width: 100px; + border-radius: 50px; + } +`; + +const Picture = ({ image }: { image: string }) => { + const sources = generateSources(image, [ + { breakpoint: breakpoints.mobile, width: 80 }, + { breakpoint: breakpoints.desktop, width: 100 }, + ]); + + const fallback = sources[0]?.lowResUrl; + + if (!fallback) throw new Error('Missing source'); + + return ( + + + + + ); +}; + +export const TagFrontHeader = ({ + title, + containerPalette, + description, + image, +}: Props) => { + const overrides = + containerPalette && decideContainerOverrides(containerPalette); + + const descriptionFragment = description + ? parseHtml(description) + : undefined; + + /** + * id is being used to set the containerId in @see {ShowMore.importable.tsx} + * this id pre-existed showMore so is probably also being used for something else. + */ + return ( +
+
+ +
+ +
+ + {image !== undefined && ( +
+ +
+ )} + + {descriptionFragment ? ( +
+ {jsx(Fragment, { + children: Array.from( + descriptionFragment.childNodes, + ).map(buildElementTree), + })} +
+ ) : null} +
+ ); +}; diff --git a/dotcom-rendering/src/components/TagFrontPage.tsx b/dotcom-rendering/src/components/TagFrontPage.tsx new file mode 100644 index 00000000000..d186de41777 --- /dev/null +++ b/dotcom-rendering/src/components/TagFrontPage.tsx @@ -0,0 +1,79 @@ +import { css, Global } from '@emotion/react'; +import { brandAlt, focusHalo, neutral } from '@guardian/source-foundations'; +import { StrictMode } from 'react'; +import { TagFrontLayout } from '../layouts/TagFrontLayout'; +import { filterABTestSwitches } from '../model/enhance-switches'; +import type { NavType } from '../model/extract-nav'; +import type { DCRTagFrontType } from '../types/tagFront'; +import { AlreadyVisited } from './AlreadyVisited.importable'; +import { AnimatePulsingDots } from './AnimatePulsingDots.importable'; +import { FetchCommentCounts } from './FetchCommentCounts.importable'; +import { FocusStyles } from './FocusStyles.importable'; +import { Island } from './Island'; +import { Metrics } from './Metrics.importable'; +import { SetABTests } from './SetABTests.importable'; +import { SkipTo } from './SkipTo'; + +type Props = { + tagFront: DCRTagFrontType; + NAV: NavType; +}; + +/** + * @description + * FrontPage is a high level wrapper for front pages on Dotcom. Sets strict mode and some globals + * + * @param {Props} props + * @param {DCRFrontType} props.front - The article JSON data + * @param {NAVType} props.NAV - The article JSON data + * */ +export const TagFrontPage = ({ tagFront, NAV }: Props) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/dotcom-rendering/src/layouts/TagFrontLayout.tsx b/dotcom-rendering/src/layouts/TagFrontLayout.tsx new file mode 100644 index 00000000000..a9d3fbe5ff8 --- /dev/null +++ b/dotcom-rendering/src/layouts/TagFrontLayout.tsx @@ -0,0 +1,301 @@ +import { css } from '@emotion/react'; +import { ArticleDesign, ArticleDisplay, ArticlePillar } from '@guardian/libs'; +import { + brandBackground, + brandBorder, + brandLine, + neutral, +} from '@guardian/source-foundations'; +import { StraightLines } from '@guardian/source-react-components-development-kitchen'; +import { AdSlot } from '../components/AdSlot'; +import { DecideContainerByTrails } from '../components/DecideContainerByTrails'; +import { Footer } from '../components/Footer'; +import { FrontSection } from '../components/FrontSection'; +import { Header } from '../components/Header'; +import { HeaderAdSlot } from '../components/HeaderAdSlot'; +import { Island } from '../components/Island'; +import { Nav } from '../components/Nav/Nav'; +import { Section } from '../components/Section'; +import { SubNav } from '../components/SubNav.importable'; +import { TagFrontHeader } from '../components/TagFrontHeader'; +import { TrendingTopics } from '../components/TrendingTopics'; +import { canRenderAds } from '../lib/canRenderAds'; +import { decidePalette } from '../lib/decidePalette'; +import { getEditionFromId } from '../lib/edition'; +import type { NavType } from '../model/extract-nav'; +import type { DCRTagFrontType } from '../types/tagFront'; +import { Stuck } from './lib/stickiness'; + +interface Props { + tagFront: DCRTagFrontType; + NAV: NavType; +} + +const getContainerId = (date: Date, locale: string, hasDay: boolean) => { + if (hasDay) { + return `${date.toLocaleDateString(locale, { + day: 'numeric', + })}-${date + .toLocaleDateString(locale, { + month: 'long', + }) + .toLowerCase()}-${date.toLocaleDateString(locale, { + year: 'numeric', + })}`; + } else { + return `${date + .toLocaleDateString(locale, { + month: 'long', + }) + .toLowerCase()}-${date.toLocaleDateString(locale, { + year: 'numeric', + })}`; + } +}; + +export const TagFrontLayout = ({ tagFront, NAV }: Props) => { + const isInEuropeTest = + tagFront.config.abTests.europeNetworkFrontVariant === 'variant'; + + const format = { + display: ArticleDisplay.Standard, + design: ArticleDesign.Standard, + theme: ArticlePillar.News, + }; + + const palette = decidePalette(format); + + /** + * This property currently only applies to the header and merchandising slots + */ + const renderAds = canRenderAds(tagFront); + + return ( + <> +
+ <> + {renderAds && ( + +
+ +
+
+ )} + +
+
+
+
+
+ {NAV.subNavSections && ( + <> +
+ + + +
+
+ +
+ + )} + +
+ +
+ + {tagFront.groupedTrails.map((groupedTrails, index) => { + const locale = getEditionFromId(tagFront.editionId).locale; + const date = new Date( + groupedTrails.year, + groupedTrails.month, + groupedTrails.day ?? 1, + ); + const containedId = getContainerId( + date, + locale, + groupedTrails.day !== undefined, + ); + + const url = + groupedTrails.day !== undefined + ? `/${tagFront.pageId}/${groupedTrails.year}/${date + .toLocaleDateString(locale, { + month: 'long', + }) + .slice(0, 3) + .toLowerCase()}/${date.toLocaleDateString( + locale, + { + day: '2-digit', + }, + )}/all` + : undefined; + + return ( + + + + ); + })} +
+
+ +
+
+ +
+ + {NAV.subNavSections && ( +
+ + + +
+ )} + +
+
+
+ + ); +}; diff --git a/dotcom-rendering/src/lib/canRenderAds.ts b/dotcom-rendering/src/lib/canRenderAds.ts index bd249c1aa94..313d194a475 100644 --- a/dotcom-rendering/src/lib/canRenderAds.ts +++ b/dotcom-rendering/src/lib/canRenderAds.ts @@ -1,12 +1,13 @@ import type { DCRFrontType } from '../types/front'; import type { FEArticleType } from '../types/frontend'; +import type { DCRTagFrontType } from '../types/tagFront'; /** * Checks the page for a number of conditions that should * prevent ads from being displayed. */ export const canRenderAds = ( - pageData: FEArticleType | DCRFrontType, + pageData: FEArticleType | DCRFrontType | DCRTagFrontType, ): boolean => { if (pageData.isAdFreeUser) { return false; diff --git a/dotcom-rendering/src/lib/cardWrappers.tsx b/dotcom-rendering/src/lib/cardWrappers.tsx index b769ede8560..a30364c588d 100644 --- a/dotcom-rendering/src/lib/cardWrappers.tsx +++ b/dotcom-rendering/src/lib/cardWrappers.tsx @@ -25,6 +25,53 @@ type TrailProps = { * https://en.wikipedia.org/wiki/Box-drawing_character */ +/** + * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ + * ┃ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒┃ + * ┃ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒┃ + * ┃ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒┃ + * ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + * Card designed to take up 100% of the container, with media that takes up 75% + * + * Options: + * - Huge headline (large on mobile) + * - Jumbo image on the right (top on mobile) + * - Trail text when there is no supporting content + * - Up to 4 supporting content items, 1-3 aligned vertical, 4 aligned horizontal + */ +export const Card100Media50 = ({ + trail, + showAge, + containerPalette, +}: TrailProps) => { + return ( + 3 + ? 'horizontal' + : 'vertical' + } + /> + ); +}; + /** * ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ * ┃ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒┃ diff --git a/dotcom-rendering/src/model/enhanceCards.ts b/dotcom-rendering/src/model/enhanceCards.ts index b863d446aab..818796d5713 100644 --- a/dotcom-rendering/src/model/enhanceCards.ts +++ b/dotcom-rendering/src/model/enhanceCards.ts @@ -137,9 +137,14 @@ const decideMediaType = (format: ArticleFormat): MediaType | undefined => { }; const decideKicker = (trail: FEFrontCard) => { - return trail.properties.isBreaking - ? 'Breaking news' - : trail.header.kicker?.item?.properties.kickerText; + if (trail.properties.isBreaking) { + return 'Breaking news'; + } + + return ( + trail.header.kicker?.item?.properties.kickerText ?? + trail.header.seriesOrBlogKicker?.name + ); }; const decideSlideshowImages = ( diff --git a/dotcom-rendering/src/model/extractTrendingTopics.ts b/dotcom-rendering/src/model/extractTrendingTopics.ts index ce6c94587d6..a11a278403c 100644 --- a/dotcom-rendering/src/model/extractTrendingTopics.ts +++ b/dotcom-rendering/src/model/extractTrendingTopics.ts @@ -49,23 +49,23 @@ const dedupeTags = (tag: FETagType[]): FETagType[] => .map((id) => tag.find((item) => item.properties.id === id)) .filter(isNonNullable); -export const extractTrendingTopics = ( +export const extractTrendingTopicsFomFront = ( collections: FECollectionType[], ): FETagType[] => { // Get a single array of all trails in the collections + const allTrails = collections.flatMap((collection) => [ ...collection.curated, ...collection.backfill, ]); + return extractTrendingTopics(allTrails); +}; +export const extractTrendingTopics = (trails: FEFrontCard[]): FETagType[] => { // Remove any duplicated trails - const dedupeTrails = (trails: FEFrontCard[]): FEFrontCard[] => - trails.filter((trailOne) => - trails.findIndex( - (trailTwo) => trailOne.card.id === trailTwo.card.id, - ), - ); - const dedupedTrails = dedupeTrails(allTrails); + const dedupedTrails = trails.filter((trailOne) => + trails.findIndex((trailTwo) => trailOne.card.id === trailTwo.card.id), + ); const allTags = getTags(dedupedTrails); // We do not wish to include section tags that follow the pattern "UK/UK" diff --git a/dotcom-rendering/src/model/front-schema.json b/dotcom-rendering/src/model/front-schema.json index 66a3dfc5104..f8e322ee624 100644 --- a/dotcom-rendering/src/model/front-schema.json +++ b/dotcom-rendering/src/model/front-schema.json @@ -726,6 +726,25 @@ "tagType", "webTitle" ] + }, + "pagination": { + "type": "object", + "properties": { + "currentPage": { + "type": "number" + }, + "lastPage": { + "type": "number" + }, + "totalContent": { + "type": "number" + } + }, + "required": [ + "currentPage", + "lastPage", + "totalContent" + ] } }, "required": [ @@ -1429,6 +1448,25 @@ "tagType", "webTitle" ] + }, + "pagination": { + "type": "object", + "properties": { + "currentPage": { + "type": "number" + }, + "lastPage": { + "type": "number" + }, + "totalContent": { + "type": "number" + } + }, + "required": [ + "currentPage", + "lastPage", + "totalContent" + ] } }, "required": [ @@ -2132,6 +2170,25 @@ "tagType", "webTitle" ] + }, + "pagination": { + "type": "object", + "properties": { + "currentPage": { + "type": "number" + }, + "lastPage": { + "type": "number" + }, + "totalContent": { + "type": "number" + } + }, + "required": [ + "currentPage", + "lastPage", + "totalContent" + ] } }, "required": [ diff --git a/dotcom-rendering/src/model/groupTrailsByDates.test.ts b/dotcom-rendering/src/model/groupTrailsByDates.test.ts new file mode 100644 index 00000000000..73941d7b991 --- /dev/null +++ b/dotcom-rendering/src/model/groupTrailsByDates.test.ts @@ -0,0 +1,77 @@ +import { trails } from '../../fixtures/manual/trails'; +import type { DCRFrontCard } from '../types/front'; +import { groupTrailsByDates } from './groupTrailsByDates'; + +const datesToTrails = (dates: Date[]): DCRFrontCard[] => { + return dates.map((date) => ({ + ...trails[0], + webPublicationDate: date.toISOString(), + })); +}; + +describe('groupTrailsByDates', () => { + it('Will split trails into days & months depending on the frequency', () => { + const dates = [ + // SHOULD BE GROUPED BY DAY + // 3 on the 23rd of June + new Date(2023, 5, 23, 12), + new Date(2023, 5, 23, 12), + new Date(2023, 5, 23, 12), + // 5 on the 25th of June + new Date(2023, 5, 25, 12), + new Date(2023, 5, 25, 12), + new Date(2023, 5, 25, 12), + new Date(2023, 5, 25, 12), + new Date(2023, 5, 25, 12), + // 7 on the 26th of June + new Date(2023, 5, 26, 12), + new Date(2023, 5, 26, 12), + new Date(2023, 5, 26, 12), + new Date(2023, 5, 26, 12), + new Date(2023, 5, 26, 12), + new Date(2023, 5, 26, 12), + + // SHOULD BE GROUPED BY MONTH + // 1 on the 2nd of May + new Date(2023, 4, 2, 12), + // 3 on 3rd of May + new Date(2023, 4, 3, 12), + new Date(2023, 4, 3, 12), + // 1 on 4th of May + new Date(2023, 4, 4, 12), + // 1 on 5th of May + new Date(2023, 4, 5, 12), + ]; + + const result = groupTrailsByDates(datesToTrails(dates)); + + expect(result[0]?.day).toEqual(23); + expect(result[1]?.day).toEqual(25); + expect(result[2]?.day).toEqual(26); + + expect(result[3]?.day).toEqual(undefined); + expect(result[3]?.month).toEqual(4); + }); + + it('Will respect "forceDay" being set to true', () => { + const dates = [ + // This would be grouped by month if left to the pop out frequency + // 1 on the 2nd of May + new Date(2023, 4, 2, 12), + // 3 on 3rd of May + new Date(2023, 4, 3, 12), + new Date(2023, 4, 3, 12), + // 1 on 4th of May + new Date(2023, 4, 4, 12), + // 1 on 5th of May + new Date(2023, 4, 5, 12), + ]; + + const result = groupTrailsByDates(datesToTrails(dates), true); + + expect(result[0]?.day).toEqual(2); + expect(result[1]?.day).toEqual(3); + expect(result[2]?.day).toEqual(4); + expect(result[3]?.day).toEqual(5); + }); +}); diff --git a/dotcom-rendering/src/model/groupTrailsByDates.ts b/dotcom-rendering/src/model/groupTrailsByDates.ts new file mode 100644 index 00000000000..b750df0a998 --- /dev/null +++ b/dotcom-rendering/src/model/groupTrailsByDates.ts @@ -0,0 +1,207 @@ +import type { DCRFrontCard } from '../types/front'; + +/** + * The number of trails per day required (on average) for trails to be + * grouped by day instead of month. + */ +const MinimumPerDayPopOutFrequency = 2; + +/** + * Helper type to give easy access to a javascript Date + * class representing the date of publication for a trail + */ +interface TrailAndDate { + trail: DCRFrontCard; + date: Date; +} + +/** + * Represents a set of trails grouped by their year, month & optionally day of publication. + */ +export interface GroupedTrails { + year: number; + month: number; + day: number | undefined; + trails: DCRFrontCard[]; +} + +/** + * Calculates the average number of trails per day across a set of days + */ +const getMeanDayFrequency = ( + days: Array<{ day: number; trails: TrailAndDate[] }>, +) => { + const totalTrails = days + .flatMap(({ trails }) => trails.length) + .reduce((acc, curr) => acc + curr, 0); + return totalTrails / days.length; +}; + +/** + * Takes a set of trails & returns them grouped by year of publication + */ +const groupByYear = (trails: TrailAndDate[]) => { + const trailsByYear: Array<{ year: number; trails: TrailAndDate[] }> = []; + + trails.forEach(({ trail, date }) => { + const existingYear = trailsByYear.find( + ({ year }) => year === date.getUTCFullYear(), + ); + if (existingYear) { + existingYear.trails.push({ trail, date }); + } else { + trailsByYear.push({ + year: date.getUTCFullYear(), + trails: [{ trail, date }], + }); + } + }); + + return trailsByYear; +}; + +/** + * Takes a set of trails for a given year and returns them grouped by month of publication + */ +const groupTrailsByMonth = (trails: TrailAndDate[], year: number) => { + const trailsByMonth: Array<{ + year: number; + month: number; + trails: TrailAndDate[]; + }> = []; + + trails.forEach(({ trail, date }) => { + const existingMonth = trailsByMonth.find( + ({ month }) => month === date.getUTCMonth(), + ); + if (existingMonth) { + existingMonth.trails.push({ trail, date }); + } else { + trailsByMonth.push({ + year, + month: date.getUTCMonth(), + trails: [{ trail, date }], + }); + } + }); + + return trailsByMonth; +}; + +/** + * Takes a set of trails for a given year & month and returns them grouped by day of publication + */ +const groupTrailsByDay = ( + trails: TrailAndDate[], + year: number, + month: number, +) => { + const trailsByDay: Array<{ + year: number; + month: number; + day: number; + trails: TrailAndDate[]; + }> = []; + + trails.forEach(({ trail, date }) => { + const existingMonth = trailsByDay.find( + ({ day }) => day === date.getUTCDate(), + ); + if (existingMonth) { + existingMonth.trails.push({ trail, date }); + } else { + trailsByDay.push({ + year, + month, + day: date.getUTCDate(), + trails: [{ trail, date }], + }); + } + }); + + return trailsByDay; +}; + +/** + * This function takes a set of trails (DCRFrontCard[]), and groups them by day, month and sometimes day. + * + * All trails will be grouped by year & month, regardless of their frequency, we then decide whether or not to + * group them by day by looking at the mean rate of occurrence per-day, when this goes above + * a pre-determined value, the entire month is 'popped out' & trails are grouped by day instead of month. + * + * You can force the 'pop out' behavior with the 'forceDay' parameter. + * + * > Note: It's important to keep in mind that you won't get only get one of grouping by day, month & year and + * just month and year. Pop-outs happen on a per-month basis, so it's likely that some groups will be just year & month + * and others grouped by day, month & year. + * + * @param trails Trails to be grouped + * @param forceDay Optionally force the trails to be grouped by day, regardless of their frequency rate + * @returns Trails grouped by either month or day + */ +export const groupTrailsByDates = ( + trails: DCRFrontCard[], + forceDay = false, +): GroupedTrails[] => { + // Creates a helper type with easy access to the date of publication as a Date class + const trailsAndDates = trails.map((trail) => ({ + trail, + date: trail.webPublicationDate + ? new Date(trail.webPublicationDate) + : new Date(), + })); + + const trailsByYear = groupByYear(trailsAndDates); + + // We will store all our grouped trails here as we work our way through each + // year, month & potentially day. + const groupedTrails: GroupedTrails[] = []; + + trailsByYear.forEach(({ year, trails: trailsForYear }) => { + const trailsByMonth = groupTrailsByMonth(trailsForYear, year); + + trailsByMonth.forEach(({ month, trails: trailsForMonth }) => { + const trailsByDay = groupTrailsByDay(trailsForMonth, year, month); + + // Once we have trails grouped by year, month & day, we calculate the day frequency + // e.g the average number of trails per day for this month. + const meanDayFrequency = getMeanDayFrequency( + trailsByDay.map(({ day, trails: dayTrails }) => ({ + day, + trails: dayTrails, + })), + ); + + // This is our 'pop-out' option, if forceDay is true of the mean day frequency exceeds + // the minimum, we will create a 'GroupedTrails' object for each day we have trails + // and add it to our output 'groupedTrails' array. + if (forceDay || meanDayFrequency > MinimumPerDayPopOutFrequency) { + groupedTrails.push( + ...trailsByDay.map((trailByDay) => ({ + day: trailByDay.day, + month, + year, + trails: trailByDay.trails.map( + (trailAndDate) => trailAndDate.trail, + ), + })), + ); + } else { + // In the case we don't 'pop-out', we create a 'GroupedTrails' object for + // all the trails of this month, and add it to our output 'groupedTrails' month. + // This is our minimum specificity, so even if we only have 1 trail for this year, + // it will be grouped by both month & year. + groupedTrails.push({ + day: undefined, + month, + year, + trails: trailsForMonth.map( + (trailAndDate) => trailAndDate.trail, + ), + }); + } + }); + }); + + return groupedTrails; +}; diff --git a/dotcom-rendering/src/model/insertPromotedNewsletter.test.ts b/dotcom-rendering/src/model/insertPromotedNewsletter.test.ts index 4203c7f154b..1f775d8606d 100644 --- a/dotcom-rendering/src/model/insertPromotedNewsletter.test.ts +++ b/dotcom-rendering/src/model/insertPromotedNewsletter.test.ts @@ -1,7 +1,10 @@ import { Live as exampleLiveBlog } from '../../fixtures/generated/articles/Live'; import { Quiz as exampleQuiz } from '../../fixtures/generated/articles/Quiz'; import { Standard as exampleStandard } from '../../fixtures/generated/articles/Standard'; -import { Newsletter, NewsletterSignupBlockElement } from '../types/content'; +import type { + Newsletter, + NewsletterSignupBlockElement, +} from '../types/content'; import { insertPromotedNewsletter } from './insertPromotedNewsletter'; const NEWSLETTER: Newsletter = { diff --git a/dotcom-rendering/src/model/slowOrFastByTrails.ts b/dotcom-rendering/src/model/slowOrFastByTrails.ts new file mode 100644 index 00000000000..c9da320ddd5 --- /dev/null +++ b/dotcom-rendering/src/model/slowOrFastByTrails.ts @@ -0,0 +1,50 @@ +import type { FEFrontCard } from '../types/front'; + +/** + * An 'arbitrary list of slow tags' once defined in Frontend. + * If you have enough of one of these tags on a Tag front then your tag front is considered 'slow' + * + * The reasoning behind why it's these tags remains unclear, so I've written a small song + * to help you come to terms this + * + * 🎶🎵🎶🎵🎶 + * And you may find yourself working on DCR + * And you may find yourself looking at the tag front code + * And you may ask yourself "What is the reason these tags are slow?" + * And you may tell yourself "The reasoning must be documented somewhere" + * And you may say to yourself "My god, why are there so many arbitrary rules?" + * + * Letting the trails go by, let the front speed hold me down... + * 🎶🎵🎶🎵🎶 + */ +const slowTags = [ + 'type/cartoon', + 'type/gallery', + 'type/picture', + 'lifeandstyle/series/last-bites', + 'artanddesign/photography', +] as const; + +const frequencyThreshold = 0.8; + +export const getSpeedFromTrails = (trails: FEFrontCard[]): 'slow' | 'fast' => { + // + const tagMap = new Map(); + + trails.forEach((trail) => { + const tags = trail.properties.maybeContent?.tags.tags; + if (tags) { + tags.forEach((tag) => { + const count = tagMap.get(tag.properties.id) ?? 0; + tagMap.set(tag.properties.id, count + 1); + }); + } + }); + + const matchingSlowTag = slowTags.find( + (tagId) => + (tagMap.get(tagId) ?? 0) / trails.length > frequencyThreshold, + ); + + return matchingSlowTag !== undefined ? 'slow' : 'fast'; +}; diff --git a/dotcom-rendering/src/model/tag-front-schema.json b/dotcom-rendering/src/model/tag-front-schema.json new file mode 100644 index 00000000000..e8cb77397c1 --- /dev/null +++ b/dotcom-rendering/src/model/tag-front-schema.json @@ -0,0 +1,1605 @@ +{ + "type": "object", + "properties": { + "contents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "isBreaking": { + "type": "boolean" + }, + "showMainVideo": { + "type": "boolean" + }, + "showKickerTag": { + "type": "boolean" + }, + "showByline": { + "type": "boolean" + }, + "imageSlideshowReplace": { + "type": "boolean" + }, + "maybeContent": { + "type": "object", + "properties": { + "trail": { + "type": "object", + "properties": { + "trailPicture": { + "type": "object", + "properties": { + "allImages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "number" + }, + "fields": { + "type": "object", + "properties": { + "displayCredit": { + "type": "string" + }, + "source": { + "type": "string" + }, + "photographer": { + "type": "string" + }, + "isMaster": { + "type": "string" + }, + "altText": { + "type": "string" + }, + "height": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "mediaId": { + "type": "string" + }, + "width": { + "type": "string" + } + }, + "required": [ + "height", + "width" + ] + }, + "mediaType": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "fields", + "index", + "mediaType", + "url" + ] + } + } + }, + "required": [ + "allImages" + ] + }, + "byline": { + "type": "string" + }, + "thumbnailPath": { + "type": "string" + }, + "webPublicationDate": { + "type": "number" + } + }, + "required": [ + "webPublicationDate" + ] + }, + "metadata": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "webTitle": { + "type": "string" + }, + "webUrl": { + "type": "string" + }, + "type": { + "type": "string" + }, + "sectionId": { + "type": "object", + "properties": { + "value": { + "type": "string" + } + }, + "required": [ + "value" + ] + }, + "format": { + "type": "object", + "properties": { + "design": { + "$ref": "#/definitions/FEDesign" + }, + "theme": { + "$ref": "#/definitions/FETheme" + }, + "display": { + "$ref": "#/definitions/FEDisplay" + } + }, + "required": [ + "design", + "display", + "theme" + ] + } + }, + "required": [ + "format", + "id", + "type", + "webTitle", + "webUrl" + ] + }, + "fields": { + "type": "object", + "properties": { + "main": { + "type": "string" + }, + "body": { + "type": "string" + }, + "standfirst": { + "type": "string" + } + }, + "required": [ + "body", + "main" + ] + }, + "elements": { + "type": "object", + "properties": { + "mainVideo": {}, + "mediaAtoms": { + "type": "array", + "items": { + "$ref": "#/definitions/FEMediaAtoms" + } + } + }, + "required": [ + "mediaAtoms" + ] + }, + "tags": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "description": "This type comes from `frontend`, hence the FE prefix.", + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tagType": { + "type": "string" + }, + "webTitle": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "bylineImageUrl": { + "type": "string" + }, + "bylineLargeImageUrl": { + "type": "string" + }, + "contributorLargeImagePath": { + "type": "string" + }, + "paidContentType": { + "type": "string" + }, + "sectionId": { + "type": "string" + }, + "sectionName": { + "type": "string" + }, + "twitterHandle": { + "type": "string" + }, + "url": { + "type": "string" + }, + "webUrl": { + "type": "string" + } + }, + "required": [ + "id", + "tagType", + "webTitle" + ] + }, + "pagination": { + "type": "object", + "properties": { + "currentPage": { + "type": "number" + }, + "lastPage": { + "type": "number" + }, + "totalContent": { + "type": "number" + } + }, + "required": [ + "currentPage", + "lastPage", + "totalContent" + ] + } + }, + "required": [ + "properties" + ] + } + } + }, + "required": [ + "tags" + ] + } + }, + "required": [ + "elements", + "fields", + "metadata", + "tags", + "trail" + ] + }, + "maybeContentId": { + "type": "string" + }, + "isLiveBlog": { + "type": "boolean" + }, + "isCrossword": { + "type": "boolean" + }, + "byline": { + "type": "string" + }, + "image": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "item": { + "type": "object", + "properties": { + "imageSrc": { + "type": "string" + }, + "assets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "imageSrc": { + "type": "string" + }, + "imageCaption": { + "type": "string" + } + }, + "required": [ + "imageSrc" + ] + } + } + } + } + }, + "required": [ + "item", + "type" + ] + }, + "webTitle": { + "type": "string" + }, + "linkText": { + "type": "string" + }, + "webUrl": { + "type": "string" + }, + "editionBrandings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "edition": { + "type": "object", + "properties": { + "id": { + "$ref": "#/definitions/EditionId" + } + }, + "required": [ + "id" + ] + }, + "branding": { + "$ref": "#/definitions/Branding" + } + }, + "required": [ + "edition" + ] + } + }, + "href": { + "type": "string" + }, + "embedUri": { + "type": "string" + } + }, + "required": [ + "editionBrandings", + "imageSlideshowReplace", + "isBreaking", + "isCrossword", + "isLiveBlog", + "showByline", + "showKickerTag", + "showMainVideo", + "webTitle" + ] + }, + "header": { + "type": "object", + "properties": { + "isVideo": { + "type": "boolean" + }, + "isComment": { + "type": "boolean" + }, + "isGallery": { + "type": "boolean" + }, + "isAudio": { + "type": "boolean" + }, + "kicker": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "item": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "kickerText": { + "type": "string" + } + }, + "required": [ + "kickerText" + ] + } + }, + "required": [ + "properties" + ] + } + }, + "required": [ + "type" + ] + }, + "seriesOrBlogKicker": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "kickerText": { + "type": "string" + } + }, + "required": [ + "kickerText" + ] + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "properties", + "url" + ] + }, + "headline": { + "type": "string" + }, + "url": { + "type": "string" + }, + "hasMainVideoElement": { + "type": "boolean" + } + }, + "required": [ + "hasMainVideoElement", + "headline", + "isAudio", + "isComment", + "isGallery", + "isVideo", + "url" + ] + }, + "card": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "cardStyle": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/FEFrontCardStyle" + } + }, + "required": [ + "type" + ] + }, + "webPublicationDateOption": { + "type": "number" + }, + "lastModifiedOption": { + "type": "number" + }, + "trailText": { + "type": "string" + }, + "starRating": { + "type": "number" + }, + "shortUrlPath": { + "type": "string" + }, + "shortUrl": { + "type": "string" + }, + "group": { + "type": "string" + }, + "isLive": { + "type": "boolean" + } + }, + "required": [ + "cardStyle", + "group", + "id", + "isLive", + "shortUrl" + ] + }, + "discussion": { + "type": "object", + "properties": { + "isCommentable": { + "type": "boolean" + }, + "isClosedForComments": { + "type": "boolean" + }, + "discussionId": { + "type": "string" + } + }, + "required": [ + "isClosedForComments", + "isCommentable" + ] + }, + "display": { + "type": "object", + "properties": { + "isBoosted": { + "type": "boolean" + }, + "showBoostedHeadline": { + "type": "boolean" + }, + "showQuotedHeadline": { + "type": "boolean" + }, + "imageHide": { + "type": "boolean" + }, + "showLivePlayable": { + "type": "boolean" + } + }, + "required": [ + "imageHide", + "isBoosted", + "showBoostedHeadline", + "showLivePlayable", + "showQuotedHeadline" + ] + }, + "format": { + "type": "object", + "properties": { + "design": { + "$ref": "#/definitions/FEDesign" + }, + "theme": { + "$ref": "#/definitions/FETheme" + }, + "display": { + "$ref": "#/definitions/FEDisplay" + } + }, + "required": [ + "design", + "display", + "theme" + ] + }, + "enriched": { + "type": "object", + "properties": { + "embedHtml": { + "type": "string" + }, + "embedCss": { + "type": "string" + }, + "embedJs": { + "type": "string" + } + } + }, + "supportingContent": { + "type": "array", + "items": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "href": { + "type": "string" + } + } + }, + "header": { + "type": "object", + "properties": { + "kicker": { + "type": "object", + "properties": { + "item": { + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "kickerText": { + "type": "string" + } + }, + "required": [ + "kickerText" + ] + } + }, + "required": [ + "properties" + ] + } + } + }, + "headline": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "headline", + "url" + ] + }, + "format": { + "type": "object", + "properties": { + "design": { + "$ref": "#/definitions/FEDesign" + }, + "theme": { + "$ref": "#/definitions/FETheme" + }, + "display": { + "$ref": "#/definitions/FEDisplay" + } + }, + "required": [ + "design", + "display", + "theme" + ] + } + }, + "required": [ + "properties" + ] + } + }, + "cardStyle": { + "type": "object", + "properties": { + "type": { + "$ref": "#/definitions/FEFrontCardStyle" + } + }, + "required": [ + "type" + ] + }, + "type": { + "type": "string" + } + }, + "required": [ + "card", + "discussion", + "display", + "header", + "properties", + "type" + ] + } + }, + "nav": { + "$ref": "#/definitions/FENavType" + }, + "tags": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "description": "This type comes from `frontend`, hence the FE prefix.", + "type": "object", + "properties": { + "properties": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tagType": { + "type": "string" + }, + "webTitle": { + "type": "string" + }, + "bio": { + "type": "string" + }, + "bylineImageUrl": { + "type": "string" + }, + "bylineLargeImageUrl": { + "type": "string" + }, + "contributorLargeImagePath": { + "type": "string" + }, + "paidContentType": { + "type": "string" + }, + "sectionId": { + "type": "string" + }, + "sectionName": { + "type": "string" + }, + "twitterHandle": { + "type": "string" + }, + "url": { + "type": "string" + }, + "webUrl": { + "type": "string" + } + }, + "required": [ + "id", + "tagType", + "webTitle" + ] + }, + "pagination": { + "type": "object", + "properties": { + "currentPage": { + "type": "number" + }, + "lastPage": { + "type": "number" + }, + "totalContent": { + "type": "number" + } + }, + "required": [ + "currentPage", + "lastPage", + "totalContent" + ] + } + }, + "required": [ + "properties" + ] + } + } + }, + "required": [ + "tags" + ] + }, + "editionId": { + "$ref": "#/definitions/EditionId" + }, + "editionLongForm": { + "type": "string" + }, + "guardianBaseURL": { + "type": "string" + }, + "pageId": { + "type": "string" + }, + "webTitle": { + "type": "string" + }, + "webURL": { + "type": "string" + }, + "config": { + "type": "object", + "properties": { + "avatarApiUrl": { + "type": "string" + }, + "externalEmbedHost": { + "type": "string" + }, + "ajaxUrl": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "revisionNumber": { + "type": "string" + }, + "isProd": { + "type": "boolean" + }, + "switches": { + "$ref": "#/definitions/Switches" + }, + "section": { + "type": "string" + }, + "keywordIds": { + "type": "string" + }, + "locationapiurl": { + "type": "string" + }, + "sharedAdTargeting": { + "type": "object", + "additionalProperties": {} + }, + "buildNumber": { + "type": "string" + }, + "abTests": { + "description": "Narrowest representation of the server-side tests\nobject shape, which is [defined in `frontend`](https://github.com/guardian/frontend/blob/23743723030a041e4f4f59fa265ee2be0bb51825/common/app/experiments/ExperimentsDefinition.scala#L24-L26).\n\n**Note:** This type is not support by JSON-schema, it evaluates as `object`", + "type": "object" + }, + "pbIndexSites": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": {} + } + }, + "ampIframeUrl": { + "type": "string" + }, + "beaconUrl": { + "type": "string" + }, + "userAttributesApiUrl": { + "type": "string" + }, + "host": { + "type": "string" + }, + "brazeApiKey": { + "type": "string" + }, + "calloutsUrl": { + "type": "string" + }, + "requiresMembershipAccess": { + "type": "boolean" + }, + "onwardWebSocket": { + "type": "string" + }, + "a9PublisherId": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "facebookIaAdUnitRoot": { + "type": "string" + }, + "ophanEmbedJsUrl": { + "type": "string" + }, + "idUrl": { + "type": "string" + }, + "dcrSentryDsn": { + "type": "string" + }, + "isFront": { + "type": "boolean", + "enum": [ + true + ] + }, + "idWebAppUrl": { + "type": "string" + }, + "discussionApiUrl": { + "type": "string" + }, + "sentryPublicApiKey": { + "type": "string" + }, + "omnitureAccount": { + "type": "string" + }, + "dfpAccountId": { + "type": "string" + }, + "pageId": { + "type": "string" + }, + "forecastsapiurl": { + "type": "string" + }, + "assetsPath": { + "type": "string" + }, + "pillar": { + "type": "string" + }, + "commercialBundleUrl": { + "type": "string" + }, + "discussionApiClientHeader": { + "type": "string" + }, + "membershipUrl": { + "type": "string" + }, + "dfpHost": { + "type": "string" + }, + "cardStyle": { + "type": "string" + }, + "googletagUrl": { + "type": "string" + }, + "sentryHost": { + "type": "string" + }, + "shouldHideAdverts": { + "type": "boolean" + }, + "mmaUrl": { + "type": "string" + }, + "membershipAccess": { + "type": "string" + }, + "isPreview": { + "type": "boolean" + }, + "googletagJsUrl": { + "type": "string" + }, + "supportUrl": { + "type": "string" + }, + "edition": { + "type": "string" + }, + "discussionFrontendUrl": { + "type": "string" + }, + "ipsosTag": { + "type": "string" + }, + "ophanJsUrl": { + "type": "string" + }, + "isPaidContent": { + "type": "boolean" + }, + "mobileAppsAdUnitRoot": { + "type": "string" + }, + "plistaPublicApiKey": { + "type": "string" + }, + "frontendAssetsFullURL": { + "type": "string" + }, + "googleSearchId": { + "type": "string" + }, + "allowUserGeneratedContent": { + "type": "boolean" + }, + "dfpAdUnitRoot": { + "type": "string" + }, + "idApiUrl": { + "type": "string" + }, + "omnitureAmpAccount": { + "type": "string" + }, + "adUnit": { + "type": "string" + }, + "hasPageSkin": { + "type": "boolean" + }, + "webTitle": { + "type": "string" + }, + "stripePublicToken": { + "type": "string" + }, + "googleRecaptchaSiteKey": { + "type": "string" + }, + "discussionD2Uid": { + "type": "string" + }, + "weatherapiurl": { + "type": "string" + }, + "googleSearchUrl": { + "type": "string" + }, + "optimizeEpicUrl": { + "type": "string" + }, + "stage": { + "$ref": "#/definitions/StageType" + }, + "idOAuthUrl": { + "type": "string" + }, + "isSensitive": { + "type": "boolean" + }, + "isDev": { + "type": "boolean" + }, + "thirdPartyAppsAccount": { + "type": "string" + }, + "avatarImagesUrl": { + "type": "string" + }, + "fbAppId": { + "type": "string" + } + }, + "required": [ + "a9PublisherId", + "abTests", + "adUnit", + "ajaxUrl", + "allowUserGeneratedContent", + "ampIframeUrl", + "assetsPath", + "avatarApiUrl", + "avatarImagesUrl", + "beaconUrl", + "buildNumber", + "calloutsUrl", + "commercialBundleUrl", + "contentType", + "dcrSentryDsn", + "dfpAccountId", + "dfpAdUnitRoot", + "dfpHost", + "discussionApiClientHeader", + "discussionApiUrl", + "discussionD2Uid", + "discussionFrontendUrl", + "edition", + "externalEmbedHost", + "facebookIaAdUnitRoot", + "fbAppId", + "forecastsapiurl", + "frontendAssetsFullURL", + "googleRecaptchaSiteKey", + "googleSearchId", + "googleSearchUrl", + "googletagJsUrl", + "googletagUrl", + "hasPageSkin", + "host", + "idApiUrl", + "idOAuthUrl", + "idUrl", + "idWebAppUrl", + "ipsosTag", + "isDev", + "isFront", + "isPreview", + "isProd", + "isSensitive", + "keywordIds", + "keywords", + "locationapiurl", + "membershipAccess", + "membershipUrl", + "mmaUrl", + "mobileAppsAdUnitRoot", + "omnitureAccount", + "omnitureAmpAccount", + "onwardWebSocket", + "ophanEmbedJsUrl", + "ophanJsUrl", + "optimizeEpicUrl", + "pageId", + "pbIndexSites", + "pillar", + "plistaPublicApiKey", + "requiresMembershipAccess", + "revisionNumber", + "section", + "sentryHost", + "sentryPublicApiKey", + "sharedAdTargeting", + "shouldHideAdverts", + "stage", + "stripePublicToken", + "supportUrl", + "switches", + "userAttributesApiUrl", + "weatherapiurl", + "webTitle" + ] + }, + "commercialProperties": { + "$ref": "#/definitions/Record" + }, + "pageFooter": { + "$ref": "#/definitions/FooterType" + }, + "isAdFreeUser": { + "type": "boolean" + }, + "forceDay": { + "type": "boolean" + } + }, + "required": [ + "commercialProperties", + "config", + "contents", + "editionId", + "editionLongForm", + "forceDay", + "guardianBaseURL", + "isAdFreeUser", + "nav", + "pageFooter", + "pageId", + "tags", + "webTitle", + "webURL" + ], + "definitions": { + "FEDesign": { + "enum": [ + "AnalysisDesign", + "ArticleDesign", + "AudioDesign", + "CommentDesign", + "DeadBlogDesign", + "EditorialDesign", + "ExplainerDesign", + "FeatureDesign", + "FullPageInteractiveDesign", + "GalleryDesign", + "InteractiveDesign", + "InterviewDesign", + "LetterDesign", + "LiveBlogDesign", + "MatchReportDesign", + "NewsletterSignupDesign", + "ObituaryDesign", + "PhotoEssayDesign", + "PrintShopDesign", + "ProfileDesign", + "QuizDesign", + "RecipeDesign", + "ReviewDesign", + "TimelineDesign", + "VideoDesign" + ], + "type": "string" + }, + "FETheme": { + "enum": [ + "CulturePillar", + "Labs", + "LifestylePillar", + "NewsPillar", + "OpinionPillar", + "SpecialReportAltTheme", + "SpecialReportTheme", + "SportPillar" + ], + "type": "string" + }, + "FEDisplay": { + "enum": [ + "ImmersiveDisplay", + "NumberedListDisplay", + "ShowcaseDisplay", + "StandardDisplay" + ], + "type": "string" + }, + "FEMediaAtoms": { + "type": "object", + "properties": { + "duration": { + "type": "number" + } + } + }, + "EditionId": { + "enum": [ + "AU", + "EUR", + "INT", + "UK", + "US" + ], + "type": "string" + }, + "Branding": { + "type": "object", + "properties": { + "brandingType": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ] + }, + "sponsorName": { + "type": "string" + }, + "logo": { + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "link": { + "type": "string" + }, + "label": { + "type": "string" + }, + "dimensions": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "height", + "width" + ] + } + }, + "required": [ + "dimensions", + "label", + "link", + "src" + ] + }, + "aboutThisLink": { + "type": "string" + }, + "logoForDarkBackground": { + "type": "object", + "properties": { + "src": { + "type": "string" + }, + "link": { + "type": "string" + }, + "label": { + "type": "string" + }, + "dimensions": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "required": [ + "height", + "width" + ] + } + }, + "required": [ + "dimensions", + "label", + "link", + "src" + ] + } + }, + "required": [ + "aboutThisLink", + "logo", + "sponsorName" + ] + }, + "FEFrontCardStyle": { + "enum": [ + "Analysis", + "Comment", + "DeadBlog", + "DefaultCardstyle", + "Editorial", + "ExternalLink", + "Feature", + "Letters", + "LiveBlog", + "Media", + "Review", + "SpecialReport", + "SpecialReportAlt" + ], + "type": "string" + }, + "FENavType": { + "type": "object", + "properties": { + "currentUrl": { + "type": "string" + }, + "pillars": { + "type": "array", + "items": { + "$ref": "#/definitions/FELinkType" + } + }, + "otherLinks": { + "type": "array", + "items": { + "$ref": "#/definitions/FELinkType" + } + }, + "brandExtensions": { + "type": "array", + "items": { + "$ref": "#/definitions/FELinkType" + } + }, + "currentNavLink": { + "$ref": "#/definitions/FELinkType" + }, + "currentNavLinkTitle": { + "type": "string" + }, + "currentPillarTitle": { + "type": "string" + }, + "subNavSections": { + "type": "object", + "properties": { + "parent": { + "$ref": "#/definitions/FELinkType" + }, + "links": { + "type": "array", + "items": { + "$ref": "#/definitions/FELinkType" + } + } + }, + "required": [ + "links" + ] + }, + "readerRevenueLinks": { + "$ref": "#/definitions/ReaderRevenuePositions" + } + }, + "required": [ + "brandExtensions", + "currentUrl", + "otherLinks", + "pillars", + "readerRevenueLinks" + ] + }, + "FELinkType": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "title": { + "type": "string" + }, + "longTitle": { + "type": "string" + }, + "iconName": { + "type": "string" + }, + "children": { + "type": "array", + "items": { + "$ref": "#/definitions/FELinkType" + } + }, + "pillar": { + "$ref": "#/definitions/LegacyPillar" + }, + "more": { + "type": "boolean" + }, + "classList": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "title", + "url" + ] + }, + "LegacyPillar": { + "enum": [ + "culture", + "labs", + "lifestyle", + "news", + "opinion", + "sport" + ], + "type": "string" + }, + "ReaderRevenuePositions": { + "type": "object", + "properties": { + "header": { + "$ref": "#/definitions/ReaderRevenueCategories" + }, + "footer": { + "$ref": "#/definitions/ReaderRevenueCategories" + }, + "sideMenu": { + "$ref": "#/definitions/ReaderRevenueCategories" + }, + "ampHeader": { + "$ref": "#/definitions/ReaderRevenueCategories" + }, + "ampFooter": { + "$ref": "#/definitions/ReaderRevenueCategories" + } + }, + "required": [ + "ampFooter", + "ampHeader", + "footer", + "header", + "sideMenu" + ] + }, + "ReaderRevenueCategories": { + "type": "object", + "properties": { + "contribute": { + "type": "string" + }, + "subscribe": { + "type": "string" + }, + "support": { + "type": "string" + }, + "supporter": { + "type": "string" + }, + "gifting": { + "type": "string" + } + }, + "required": [ + "contribute", + "subscribe", + "support", + "supporter" + ] + }, + "Switches": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "StageType": { + "enum": [ + "CODE", + "DEV", + "PROD" + ], + "type": "string" + }, + "Record": { + "type": "object" + }, + "FooterType": { + "type": "object", + "properties": { + "footerLinks": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/FooterLink" + } + } + } + }, + "required": [ + "footerLinks" + ] + }, + "FooterLink": { + "type": "object", + "properties": { + "text": { + "type": "string" + }, + "url": { + "type": "string" + }, + "dataLinkName": { + "type": "string" + }, + "extraClasses": { + "type": "string" + } + }, + "required": [ + "dataLinkName", + "text", + "url" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} \ No newline at end of file diff --git a/dotcom-rendering/src/model/validate.ts b/dotcom-rendering/src/model/validate.ts index 52564bbd719..dc81b30353e 100644 --- a/dotcom-rendering/src/model/validate.ts +++ b/dotcom-rendering/src/model/validate.ts @@ -5,10 +5,12 @@ import addFormats from 'ajv-formats'; import type { FEFrontType } from '../../src/types/front'; import type { FEArticleType } from '../types/frontend'; import type { FENewslettersPageType } from '../types/newslettersPage'; +import type { FETagFrontType } from '../types/tagFront'; import articleSchema from './article-schema.json'; import blockSchema from './block-schema.json'; import frontSchema from './front-schema.json'; import newslettersPageSchema from './newsletter-page-schema.json'; +import tagFrontSchema from './tag-front-schema.json'; const options: Options = { verbose: false, @@ -22,6 +24,7 @@ addFormats(ajv); const validateArticle = ajv.compile(articleSchema); const validateFront = ajv.compile(frontSchema); +const validateTagFront = ajv.compile(tagFrontSchema); const validateAllEditorialNewslettersPage = ajv.compile( newslettersPageSchema, ); @@ -51,6 +54,18 @@ export const validateAsFrontType = (data: unknown): FEFrontType => { ); }; +export const validateAsTagFrontType = (data: unknown): FETagFrontType => { + if (validateTagFront(data)) return data; + + const url = + isObject(data) && isString(data.webURL) ? data.webURL : 'unknown url'; + + throw new TypeError( + `Unable to validate request body for url ${url}.\n + ${JSON.stringify(validateTagFront.errors, null, 2)}`, + ); +}; + export const validateAsAllEditorialNewslettersPageType = ( data: unknown, ): FENewslettersPageType => { diff --git a/dotcom-rendering/src/server/dev-index.html b/dotcom-rendering/src/server/dev-index.html index 8568393e227..8f9fdbf616d 100644 --- a/dotcom-rendering/src/server/dev-index.html +++ b/dotcom-rendering/src/server/dev-index.html @@ -63,18 +63,29 @@

Default Endpoints

International Front + (Enhanced JSON) + +
  • + Tag Front (Enhanced JSON)
  • - 📱 Apps Article
  • - 📧 All Newsletters page
  • diff --git a/dotcom-rendering/src/server/dev-server.ts b/dotcom-rendering/src/server/dev-server.ts index cce9f5cdc8c..fe24adb79a9 100644 --- a/dotcom-rendering/src/server/dev-server.ts +++ b/dotcom-rendering/src/server/dev-server.ts @@ -9,12 +9,19 @@ import { handleInteractive, handleKeyEvents, } from '../server/index.article.web'; -import { handleFront, handleFrontJson } from '../server/index.front.web'; +import { + handleFront, + handleFrontJson, + handleTagFront, + handleTagFrontJson, +} from '../server/index.front.web'; /** article URLs contain a part that looks like “2022/nov/25” */ const ARTICLE_URL = /\/\d{4}\/[a-z]{3}\/\d{2}\//; /** fronts are a series of lowercase strings, dashes and forward slashes */ const FRONT_URL = /^\/[a-z-/]+/; +/** This is imperfect, but covers *some* cases of tag fronts, consider expanding in the future */ +const TAG_FRONT_URL = /^\/(tone|series|profile)\/[a-z-]+/; /** assets are paths like /assets/index.xxx.js */ const ASSETS_URL = /^assets\/.+\.js/; @@ -49,6 +56,10 @@ export const devServer = (): Handler => { return handleFront(req, res, next); case 'FrontJSON': return handleFrontJson(req, res, next); + case 'TagFront': + return handleTagFront(req, res, next); + case 'TagFrontJSON': + return handleTagFrontJson(req, res, next); case 'EmailNewsletters': return handleAllEditorialNewslettersPage(req, res, next); case 'AppsArticle': @@ -66,6 +77,15 @@ export const devServer = (): Handler => { return res.redirect(`/Article/${url}`); } + if (req.url.match(TAG_FRONT_URL)) { + const url = new URL( + req.url, + 'https://www.theguardian.com/', + ).toString(); + console.info('redirecting to Tag front:', url); + return res.redirect(`/TagFront/${url}`); + } + if (req.url.match(FRONT_URL)) { const url = new URL( req.url, diff --git a/dotcom-rendering/src/server/index.front.web.ts b/dotcom-rendering/src/server/index.front.web.ts index dadf06e8e91..150ed8e79fa 100644 --- a/dotcom-rendering/src/server/index.front.web.ts +++ b/dotcom-rendering/src/server/index.front.web.ts @@ -1,11 +1,18 @@ import type { RequestHandler } from 'express'; import { decideTrail } from '../lib/decideTrail'; +import { enhanceCards } from '../model/enhanceCards'; import { enhanceCollections } from '../model/enhanceCollections'; -import { extractTrendingTopics } from '../model/extractTrendingTopics'; -import { validateAsFrontType } from '../model/validate'; +import { + extractTrendingTopics, + extractTrendingTopicsFomFront, +} from '../model/extractTrendingTopics'; +import { groupTrailsByDates } from '../model/groupTrailsByDates'; +import { getSpeedFromTrails } from '../model/slowOrFastByTrails'; +import { validateAsFrontType, validateAsTagFrontType } from '../model/validate'; import { recordTypeAndPlatform } from '../server/lib/logging-store'; import type { DCRFrontType, FEFrontType } from '../types/front'; -import { renderFront } from './render.front.web'; +import type { DCRTagFrontType, FETagFrontType } from '../types/tagFront'; +import { renderFront, renderTagFront } from './render.front.web'; const enhanceFront = (body: unknown): DCRFrontType => { const data: FEFrontType = validateAsFrontType(body); @@ -29,11 +36,46 @@ const enhanceFront = (body: unknown): DCRFrontType => { ? decideTrail(data.mostCommented) : undefined, mostShared: data.mostShared ? decideTrail(data.mostShared) : undefined, - trendingTopics: extractTrendingTopics(data.pressedPage.collections), + trendingTopics: extractTrendingTopicsFomFront( + data.pressedPage.collections, + ), deeplyRead: data.deeplyRead?.map((trail) => decideTrail(trail)), }; }; +const enhanceTagFront = (body: unknown): DCRTagFrontType => { + const data: FETagFrontType = validateAsTagFrontType(body); + + const enhancedCards = enhanceCards(data.contents, {}); + const speed = getSpeedFromTrails(data.contents); + + return { + ...data, + tags: data.tags.tags, + groupedTrails: groupTrailsByDates( + enhancedCards, + speed === 'slow' || data.forceDay, + ), + speed, + // Pagination information comes from the first tag + pagination: + data.tags.tags[0]?.pagination && + data.tags.tags[0].pagination.lastPage > 1 + ? { + ...data.tags.tags[0]?.pagination, + sectionName: data.webTitle, + pageId: data.pageId, + } + : undefined, + trendingTopics: extractTrendingTopics(data.contents), + header: { + title: data.webTitle, + description: data.tags.tags[0]?.properties.bio, + image: data.tags.tags[0]?.properties.bylineImageUrl, + }, + }; +}; + export const handleFront: RequestHandler = ({ body }, res) => { recordTypeAndPlatform('front'); const front = enhanceFront(body); @@ -46,3 +88,16 @@ export const handleFront: RequestHandler = ({ body }, res) => { export const handleFrontJson: RequestHandler = ({ body }, res) => { res.json(enhanceFront(body)); }; + +export const handleTagFront: RequestHandler = ({ body }, res) => { + recordTypeAndPlatform('tagFront'); + const tagFront = enhanceTagFront(body); + const html = renderTagFront({ + tagFront, + }); + res.status(200).send(html); +}; + +export const handleTagFrontJson: RequestHandler = ({ body }, res) => { + res.json(enhanceTagFront(body)); +}; diff --git a/dotcom-rendering/src/server/prod-server.ts b/dotcom-rendering/src/server/prod-server.ts index 395eb416670..a9076560462 100644 --- a/dotcom-rendering/src/server/prod-server.ts +++ b/dotcom-rendering/src/server/prod-server.ts @@ -17,7 +17,12 @@ import { handleInteractive, handleKeyEvents, } from '../server/index.article.web'; -import { handleFront, handleFrontJson } from '../server/index.front.web'; +import { + handleFront, + handleFrontJson, + handleTagFront, + handleTagFrontJson, +} from '../server/index.front.web'; import { recordBaselineCloudWatchMetrics } from './lib/aws/metrics-baseline'; import { getContentFromURLMiddleware } from './lib/get-content-from-url'; import { logger } from './lib/logging'; @@ -62,6 +67,8 @@ export const prodServer = (): void => { app.post('/KeyEvents', logRenderTime, handleKeyEvents); app.post('/Front', logRenderTime, handleFront); app.post('/FrontJSON', logRenderTime, handleFrontJson); + app.post('/TagFront', logRenderTime, handleTagFront); + app.post('/TagFrontJSON', logRenderTime, handleTagFrontJson); app.post( '/EmailNewsletters', logRenderTime, @@ -98,6 +105,19 @@ export const prodServer = (): void => { handleFrontJson, ); + app.get( + '/TagFront/*', + logRenderTime, + getContentFromURLMiddleware, + handleTagFront, + ); + app.get( + '/TagFrontJSON/*', + logRenderTime, + getContentFromURLMiddleware, + handleTagFrontJson, + ); + app.get( '/EmailNewsletters', logRenderTime, diff --git a/dotcom-rendering/src/server/render.front.web.tsx b/dotcom-rendering/src/server/render.front.web.tsx index 4abfab5e7e2..ae7ba426caa 100644 --- a/dotcom-rendering/src/server/render.front.web.tsx +++ b/dotcom-rendering/src/server/render.front.web.tsx @@ -4,6 +4,7 @@ import { dcrJavascriptBundle, } from '../../scripts/webpack/bundles'; import { FrontPage } from '../components/FrontPage'; +import { TagFrontPage } from '../components/TagFrontPage'; import { generateScriptTags, getScriptsFromManifest } from '../lib/assets'; import { renderToStringWithEmotion } from '../lib/emotion'; import { escapeData } from '../lib/escapeData'; @@ -11,6 +12,7 @@ import { getHttp3Url } from '../lib/getHttp3Url'; import { extractNAV } from '../model/extract-nav'; import { makeWindowGuardian } from '../model/window-guardian'; import type { DCRFrontType } from '../types/front'; +import type { DCRTagFrontType } from '../types/tagFront'; import { htmlPageTemplate } from './htmlPageTemplate'; interface Props { @@ -111,3 +113,102 @@ export const renderFront = ({ front }: Props): string => { borkFID: front.config.abTests.borkFidVariant === 'variant', }); }; + +export const renderTagFront = ({ + tagFront, +}: { + tagFront: DCRTagFrontType; +}): string => { + const title = tagFront.webTitle; + const NAV = extractNAV(tagFront.nav); + + const { html, extractedCss } = renderToStringWithEmotion( + , + ); + + // Evaluating the performance of HTTP3 over HTTP2 + // See: https://github.com/guardian/dotcom-rendering/pull/5394 + const { offerHttp3 = false } = tagFront.config.switches; + + const polyfillIO = + 'https://assets.guim.co.uk/polyfill.io/v3/polyfill.min.js?rum=0&features=es6,es7,es2017,es2018,es2019,default-3.6,HTMLPictureElement,IntersectionObserver,IntersectionObserverEntry,URLSearchParams,fetch,NodeList.prototype.forEach,navigator.sendBeacon,performance.now,Promise.allSettled&flags=gated&callback=guardianPolyfilled&unknown=polyfill&cacheClear=1'; + + const shouldServeVariantBundle: boolean = [ + BUILD_VARIANT, + tagFront.config.abTests[dcrJavascriptBundle('Variant')] === 'variant', + ].every(Boolean); + + /** + * This function returns an array of files found in the manifests + * defined by `manifestPaths`. + * + * @see getScriptsFromManifest + */ + const getScriptArrayFromFile = getScriptsFromManifest({ + platform: 'web', + shouldServeVariantBundle, + }); + + /** + * The highest priority scripts. + * These scripts have a considerable impact on site performance. + * Only scripts critical to application execution may go in here. + * Please talk to the dotcom platform team before adding more. + * Scripts will be executed in the order they appear in this array + */ + const scriptTags = generateScriptTags( + [ + polyfillIO, + ...getScriptArrayFromFile('frameworks.js'), + ...getScriptArrayFromFile('index.js'), + process.env.COMMERCIAL_BUNDLE_URL ?? + tagFront.config.commercialBundleUrl, + ] + .filter(isString) + .map((script) => (offerHttp3 ? getHttp3Url(script) : script)), + ); + + /** + * We escape windowGuardian here to prevent errors when the data + * is placed in a script tag on the page + */ + const windowGuardian = escapeData( + JSON.stringify( + makeWindowGuardian({ + editionId: tagFront.editionId, + stage: tagFront.config.stage, + frontendAssetsFullURL: tagFront.config.frontendAssetsFullURL, + revisionNumber: tagFront.config.revisionNumber, + sentryPublicApiKey: tagFront.config.sentryPublicApiKey, + sentryHost: tagFront.config.sentryHost, + keywordIds: tagFront.config.keywordIds, + dfpAccountId: tagFront.config.dfpAccountId, + adUnit: tagFront.config.adUnit, + ajaxUrl: tagFront.config.ajaxUrl, + googletagUrl: tagFront.config.googletagUrl, + switches: tagFront.config.switches, + abTests: tagFront.config.abTests, + brazeApiKey: tagFront.config.brazeApiKey, + // Until we understand exactly what config we need to make available client-side, + // add everything we haven't explicitly typed as unknown config + unknownConfig: tagFront.config, + }), + ), + ); + + const keywords = tagFront.config.keywords; + + return htmlPageTemplate({ + scriptTags, + css: extractedCss, + html, + title, + description: tagFront.header.description, + windowGuardian, + keywords, + offerHttp3, + renderingTarget: 'Web', + borkFCP: tagFront.config.abTests.borkFcpVariant === 'variant', + borkFID: tagFront.config.abTests.borkFidVariant === 'variant', + }); +}; diff --git a/dotcom-rendering/src/types/front.ts b/dotcom-rendering/src/types/front.ts index 4002ad14efd..b4c0913b341 100644 --- a/dotcom-rendering/src/types/front.ts +++ b/dotcom-rendering/src/types/front.ts @@ -385,7 +385,7 @@ export type DCRGroupedTrails = { standard: DCRFrontCard[]; }; -type FEFrontConfigType = { +export type FEFrontConfigType = { avatarApiUrl: string; externalEmbedHost: string; ajaxUrl: string; diff --git a/dotcom-rendering/src/types/tag.ts b/dotcom-rendering/src/types/tag.ts index 4b2dee5b58e..7e9cac15d69 100644 --- a/dotcom-rendering/src/types/tag.ts +++ b/dotcom-rendering/src/types/tag.ts @@ -20,6 +20,11 @@ export type FETagType = { url?: string; webUrl?: string; }; + pagination?: { + currentPage: number; + lastPage: number; + totalContent: number; + }; }; export type TagType = { diff --git a/dotcom-rendering/src/types/tagFront.ts b/dotcom-rendering/src/types/tagFront.ts new file mode 100644 index 00000000000..d0cff6ac6ce --- /dev/null +++ b/dotcom-rendering/src/types/tagFront.ts @@ -0,0 +1,56 @@ +import type { EditionId } from '../lib/edition'; +import type { GroupedTrails } from '../model/groupTrailsByDates'; +import type { FooterType } from './footer'; +import type { FEFrontCard, FEFrontConfigType } from './front'; +import type { FETagType } from './tag'; + +export interface FETagFrontType { + contents: FEFrontCard[]; + nav: FENavType; + tags: { + tags: FETagType[]; + }; + editionId: EditionId; + editionLongForm: string; + guardianBaseURL: string; + pageId: string; + webTitle: string; + webURL: string; + config: FEFrontConfigType; + commercialProperties: Record; + pageFooter: FooterType; + isAdFreeUser: boolean; + forceDay: boolean; +} + +export interface DCRTagFrontType { + groupedTrails: GroupedTrails[]; + nav: FENavType; + tags: FETagType[]; + editionId: EditionId; + editionLongForm: string; + guardianBaseURL: string; + pageId: string; + webTitle: string; + webURL: string; + config: FEFrontConfigType; + commercialProperties: Record; + pageFooter: FooterType; + isAdFreeUser: boolean; + trendingTopics?: FETagType[]; + speed: 'slow' | 'fast'; + pagination?: DCRFrontPagination; + header: { + title: string; + description?: string; + image?: string; + }; +} + +export interface DCRFrontPagination { + pageId: string; + sectionName: string; + currentPage: number; + lastPage: number; + totalContent: number; +}