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 (
+ <>
+ ;
+
+ {afterEight.map((trail, index) => (
+ -
+
+
+ ))}
+
+ >
+ );
+};
+
+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*/}
-
-
- );
- })}
-
+

{
+ 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;
+}