;
+ adIndex: number;
+}) => {
+ return (
+ <>
+
+
+
+ >
+ );
+};
+
+type Props = GroupedTrailsSlowMpu & {
+ adIndex: number;
+};
+
+export const TagFrontSlowMpu = ({ trails, adIndex }: Props) => {
+ switch (trails.length) {
+ case 2:
+ return ;
+ case 4:
+ return ;
+ case 5:
+ return ;
+ case 7:
+ return ;
+ }
+};
diff --git a/dotcom-rendering/src/layouts/FrontLayout.tsx b/dotcom-rendering/src/layouts/FrontLayout.tsx
index b969ce58e6e..f737062ff79 100644
--- a/dotcom-rendering/src/layouts/FrontLayout.tsx
+++ b/dotcom-rendering/src/layouts/FrontLayout.tsx
@@ -75,7 +75,7 @@ const isToggleable = (
} else return index != 0 && !isNavList(collection);
};
-const decideAdSlot = (
+export const decideAdSlot = (
renderAds: boolean,
index: number,
isNetworkFront: boolean | undefined,
diff --git a/dotcom-rendering/src/layouts/TagFrontLayout.tsx b/dotcom-rendering/src/layouts/TagFrontLayout.tsx
index 4fb7dd97394..35d3b6d9150 100644
--- a/dotcom-rendering/src/layouts/TagFrontLayout.tsx
+++ b/dotcom-rendering/src/layouts/TagFrontLayout.tsx
@@ -8,6 +8,7 @@ import {
news,
} from '@guardian/source-foundations';
import { StraightLines } from '@guardian/source-react-components-development-kitchen';
+import { Fragment } from 'react';
import { AdSlot } from '../components/AdSlot';
import { DecideContainerByTrails } from '../components/DecideContainerByTrails';
import { Footer } from '../components/Footer';
@@ -18,13 +19,20 @@ import { Island } from '../components/Island';
import { Nav } from '../components/Nav/Nav';
import { Section } from '../components/Section';
import { SubNav } from '../components/SubNav.importable';
+import { TagFrontFastMpu } from '../components/TagFrontFastMpu';
import { TagFrontHeader } from '../components/TagFrontHeader';
+import { TagFrontSlowMpu } from '../components/TagFrontSlowMpu';
import { TrendingTopics } from '../components/TrendingTopics';
import { canRenderAds } from '../lib/canRenderAds';
import { decidePalette } from '../lib/decidePalette';
import { getEditionFromId } from '../lib/edition';
+import {
+ getMerchHighPosition,
+ getTagFrontMobileAdPositions,
+} from '../lib/getAdPositions';
import type { NavType } from '../model/extract-nav';
import type { DCRTagFrontType } from '../types/tagFront';
+import { decideAdSlot } from './FrontLayout';
import { Stuck } from './lib/stickiness';
interface Props {
@@ -66,11 +74,23 @@ export const TagFrontLayout = ({ tagFront, NAV }: Props) => {
const palette = decidePalette(format);
+ const merchHighPosition = getMerchHighPosition(
+ tagFront.groupedTrails.length,
+ false,
+ );
+
/**
* This property currently only applies to the header and merchandising slots
*/
const renderAds = canRenderAds(tagFront);
+ const mobileAdPositions = renderAds
+ ? getTagFrontMobileAdPositions(
+ tagFront.groupedTrails,
+ merchHighPosition,
+ )
+ : [];
+
return (
<>
@@ -194,6 +214,35 @@ export const TagFrontLayout = ({ tagFront, NAV }: Props) => {
groupedTrails.day !== undefined,
);
+ const ContainerComponent = () => {
+ if (
+ 'injected' in groupedTrails &&
+ 'speed' in groupedTrails
+ ) {
+ if (groupedTrails.speed === 'fast') {
+ return (
+
+ );
+ } else {
+ return (
+
+ );
+ }
+ }
+ return (
+
+ );
+ };
+
const url =
groupedTrails.day !== undefined
? `/${tagFront.pageId}/${groupedTrails.year}/${date
@@ -210,37 +259,44 @@ export const TagFrontLayout = ({ tagFront, NAV }: Props) => {
: undefined;
return (
-
-
-
+
+
+
+
+ {decideAdSlot(
+ renderAds,
+ index,
+ false,
+ tagFront.groupedTrails.length,
+ tagFront.config.isPaidContent,
+ mobileAdPositions,
+ tagFront.config.hasPageSkin,
+ )}
+
);
})}
diff --git a/dotcom-rendering/src/lib/getAdPositions.ts b/dotcom-rendering/src/lib/getAdPositions.ts
index cebb1a0ff02..005a09d87d4 100644
--- a/dotcom-rendering/src/lib/getAdPositions.ts
+++ b/dotcom-rendering/src/lib/getAdPositions.ts
@@ -1,4 +1,5 @@
import type { DCRCollectionType } from '../types/front';
+import type { GroupedTrailsBase } from '../types/tagFront';
type AdCandidate = Pick;
@@ -74,6 +75,35 @@ export const getMobileAdPositions = (
// Should insert no more than 10 ads
.slice(0, 10);
+/**
+ * Uses a very similar approach to pressed fronts, except we
+ * - Do not need to consider thrashers
+ * - Do not need to consider the 'most viewed' container
+ *
+ * The types are also slightly different, as we no longer have
+ * specific container IDs, so we use the date which is unique
+ */
+export const getTagFrontMobileAdPositions = (
+ collections: Array,
+ merchHighPosition: number,
+): number[] =>
+ collections
+ .filter(
+ (_, index) =>
+ !hasAdjacentCommercialContainer(index, merchHighPosition),
+ )
+ .filter(isEvenIndex)
+ .map((collection) =>
+ collections.findIndex(
+ ({ day, month, year }) =>
+ day === collection.day &&
+ month === collection.month &&
+ year === collection.year,
+ ),
+ )
+ // Should insert no more than 10 ads
+ .slice(0, 10);
+
const hasDesktopAd = (collection: AdCandidate) => {
return (
collection.collectionType == 'dynamic/slow-mpu' ||
diff --git a/dotcom-rendering/src/lib/tuple.test.ts b/dotcom-rendering/src/lib/tuple.test.ts
new file mode 100644
index 00000000000..8751a04161c
--- /dev/null
+++ b/dotcom-rendering/src/lib/tuple.test.ts
@@ -0,0 +1,83 @@
+import { takeFirst } from './tuple';
+
+describe('takeFirst', () => {
+ it('Always returns the correct array length when the array is one less, the same as, or one more than n', () => {
+ const results = [
+ // Format, from 1 to 12, cover length n - 1, n & n+1
+ takeFirst([0, 1], 1),
+ takeFirst([0], 1),
+ takeFirst([], 1),
+ takeFirst([0, 1, 2], 2),
+ takeFirst([0, 1], 2),
+ takeFirst([0], 2),
+ takeFirst([0, 1, 2, 3], 3),
+ takeFirst([0, 1, 2], 3),
+ takeFirst([0, 1], 3),
+ takeFirst([0, 1, 2, 3, 4], 4),
+ takeFirst([0, 1, 2, 3], 4),
+ takeFirst([0, 1, 2], 4),
+ takeFirst([0, 1, 2, 3, 4, 5], 5),
+ takeFirst([0, 1, 2, 3, 4], 5),
+ takeFirst([0, 1, 2, 3], 5),
+ takeFirst([0, 1, 2, 3, 4, 5, 6], 6),
+ takeFirst([0, 1, 2, 3, 4, 5], 6),
+ takeFirst([0, 1, 2, 3, 4], 6),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7], 7),
+ takeFirst([0, 1, 2, 3, 4, 5, 6], 7),
+ takeFirst([0, 1, 2, 3, 4, 5], 7),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8], 8),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7], 8),
+ takeFirst([0, 1, 2, 3, 4, 5, 6], 8),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 9),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8], 9),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7], 9),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 10),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 10),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8], 10),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 11),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 11),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9], 11),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 12),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], 12),
+ takeFirst([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 12),
+ ] as const;
+
+ // Expected results from n are n, n & n-1
+ expect(results[0].length).toEqual(1);
+ expect(results[1].length).toEqual(1);
+ expect(results[2].length).toEqual(0);
+ expect(results[3].length).toEqual(2);
+ expect(results[4].length).toEqual(2);
+ expect(results[5].length).toEqual(1);
+ expect(results[6].length).toEqual(3);
+ expect(results[7].length).toEqual(3);
+ expect(results[8].length).toEqual(2);
+ expect(results[9].length).toEqual(4);
+ expect(results[10].length).toEqual(4);
+ expect(results[11].length).toEqual(3);
+ expect(results[12].length).toEqual(5);
+ expect(results[13].length).toEqual(5);
+ expect(results[14].length).toEqual(4);
+ expect(results[15].length).toEqual(6);
+ expect(results[16].length).toEqual(6);
+ expect(results[17].length).toEqual(5);
+ expect(results[18].length).toEqual(7);
+ expect(results[19].length).toEqual(7);
+ expect(results[20].length).toEqual(6);
+ expect(results[21].length).toEqual(8);
+ expect(results[22].length).toEqual(8);
+ expect(results[23].length).toEqual(7);
+ expect(results[24].length).toEqual(9);
+ expect(results[25].length).toEqual(9);
+ expect(results[26].length).toEqual(8);
+ expect(results[27].length).toEqual(10);
+ expect(results[28].length).toEqual(10);
+ expect(results[29].length).toEqual(9);
+ expect(results[30].length).toEqual(11);
+ expect(results[31].length).toEqual(11);
+ expect(results[32].length).toEqual(10);
+ expect(results[33].length).toEqual(12);
+ expect(results[34].length).toEqual(12);
+ expect(results[35].length).toEqual(11);
+ });
+});
diff --git a/dotcom-rendering/src/lib/tuple.ts b/dotcom-rendering/src/lib/tuple.ts
new file mode 100644
index 00000000000..a93956eab63
--- /dev/null
+++ b/dotcom-rendering/src/lib/tuple.ts
@@ -0,0 +1,84 @@
+/** A tuple of up to 12 items. Larger tuples will not be narrowed */
+export type Tuple = N extends 12
+ ? [T, T, T, T, T, T, T, T, T, T, T, T]
+ : N extends 11
+ ? [T, T, T, T, T, T, T, T, T, T, T]
+ : N extends 10
+ ? [T, T, T, T, T, T, T, T, T, T]
+ : N extends 9
+ ? [T, T, T, T, T, T, T, T, T]
+ : N extends 8
+ ? [T, T, T, T, T, T, T, T]
+ : N extends 7
+ ? [T, T, T, T, T, T, T]
+ : N extends 6
+ ? [T, T, T, T, T, T]
+ : N extends 5
+ ? [T, T, T, T, T]
+ : N extends 4
+ ? [T, T, T, T]
+ : N extends 3
+ ? [T, T, T]
+ : N extends 2
+ ? [T, T]
+ : N extends 1
+ ? [T]
+ : N extends 0
+ ? []
+ : T[];
+
+/**
+ * Type-guard for whether an array is a tuple of exact length.
+ *
+ * Only tuples of 12 elements or less will be narrowed.
+ */
+export const isTuple = (
+ arr: Array | ReadonlyArray,
+ count: N,
+): arr is Tuple => arr.length === count;
+
+/** Type where a tuple can have any 'n' number of items or less */
+type SlicedTuple<
+ T,
+ N extends 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12,
+> = N extends 12
+ ? Tuple
+ : N extends 11
+ ? Tuple
+ : N extends 10
+ ? Tuple
+ : N extends 9
+ ? Tuple
+ : N extends 8
+ ? Tuple
+ : N extends 7
+ ? Tuple
+ : N extends 6
+ ? Tuple
+ : N extends 5
+ ? Tuple
+ : N extends 4
+ ? Tuple
+ : N extends 3
+ ? Tuple
+ : N extends 2
+ ? Tuple
+ : N extends 1
+ ? Tuple
+ : undefined;
+
+/**
+ * Takes the first 'n' number of items in an array
+ *
+ * By returning `SlicedTuple` you receive a type-safe response
+ * that can be checked exhaustively.
+ */
+export const takeFirst = <
+ T,
+ N extends 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12,
+>(
+ array: Array | ReadonlyArray,
+ count: N,
+): SlicedTuple =>
+ //@ts-expect-error – this output is tested by jest and it’s a very helpful method
+ array.slice(0, count);
diff --git a/dotcom-rendering/src/model/groupTrailsByDates.ts b/dotcom-rendering/src/model/groupTrailsByDates.ts
index b750df0a998..687fe2b1567 100644
--- a/dotcom-rendering/src/model/groupTrailsByDates.ts
+++ b/dotcom-rendering/src/model/groupTrailsByDates.ts
@@ -1,4 +1,5 @@
import type { DCRFrontCard } from '../types/front';
+import type { GroupedTrails } from '../types/tagFront';
/**
* The number of trails per day required (on average) for trails to be
@@ -15,16 +16,6 @@ interface TrailAndDate {
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
*/
diff --git a/dotcom-rendering/src/model/injectMpuIntoGroupedTrails.ts b/dotcom-rendering/src/model/injectMpuIntoGroupedTrails.ts
new file mode 100644
index 00000000000..f8753ed2bfe
--- /dev/null
+++ b/dotcom-rendering/src/model/injectMpuIntoGroupedTrails.ts
@@ -0,0 +1,81 @@
+import { takeFirst } from '../lib/tuple';
+import type {
+ GroupedTrails,
+ GroupedTrailsFastMpu,
+ GroupedTrailsSlowMpu,
+} from '../types/tagFront';
+
+/**
+ * Injects an MPU container into a list of grouped trails
+ *
+ * For both slow & fast tag fronts, containers of certain lengths can receive an MPU slot.
+ * The code looks for the first container of the right length and injects the ad slot
+ * object (GroupedTrailsSlowMpu / GroupedTrailsFastMpu).
+ */
+export const injectMpuIntoGroupedTrails = (
+ groupedTrails: GroupedTrails[],
+ speed: 'slow' | 'fast',
+): Array => {
+ let injected = false;
+ const result: Array<
+ GroupedTrails | GroupedTrailsFastMpu | GroupedTrailsSlowMpu
+ > = [];
+
+ groupedTrails.forEach((grouped) => {
+ if (injected) {
+ result.push(grouped);
+ return;
+ }
+
+ if (speed === 'fast') {
+ // When we have a container with > 9 trails for fast,
+ // we 'cap' the number of trails at 9 in order to fit an MPU in.
+ // Containers that don't get an MPU injected will of course still be
+ // able to show more than 9 trails.
+ const fastTrails = takeFirst(grouped.trails, 9);
+ switch (fastTrails.length) {
+ case 2:
+ case 4:
+ case 6:
+ case 9:
+ injected = true;
+ result.push({
+ day: grouped.day,
+ month: grouped.month,
+ year: grouped.year,
+ trails: fastTrails,
+ injected: true,
+ speed: 'fast',
+ });
+ break;
+ default:
+ break;
+ }
+ } else {
+ // By taking the first 12, we get the benefit of being able to use
+ // a switch statement here, without 'capping' the number of trails in the
+ // same way we do when 'fast'
+ const slowTrails = takeFirst(grouped.trails, 12);
+ switch (slowTrails.length) {
+ case 2:
+ case 4:
+ case 5:
+ case 7:
+ injected = true;
+ result.push({
+ day: grouped.day,
+ month: grouped.month,
+ year: grouped.year,
+ trails: slowTrails,
+ injected: true,
+ speed: 'slow',
+ });
+ break;
+ default:
+ break;
+ }
+ }
+ });
+
+ return result;
+};
diff --git a/dotcom-rendering/src/server/index.front.web.ts b/dotcom-rendering/src/server/index.front.web.ts
index 36f97e26980..559a6428af8 100644
--- a/dotcom-rendering/src/server/index.front.web.ts
+++ b/dotcom-rendering/src/server/index.front.web.ts
@@ -7,6 +7,7 @@ import {
extractTrendingTopicsFomFront,
} from '../model/extractTrendingTopics';
import { groupTrailsByDates } from '../model/groupTrailsByDates';
+import { injectMpuIntoGroupedTrails } from '../model/injectMpuIntoGroupedTrails';
import { getSpeedFromTrails } from '../model/slowOrFastByTrails';
import { validateAsFrontType, validateAsTagFrontType } from '../model/validate';
import { recordTypeAndPlatform } from '../server/lib/logging-store';
@@ -54,13 +55,17 @@ const enhanceTagFront = (body: unknown): DCRTagFrontType => {
});
const speed = getSpeedFromTrails(data.contents);
+ const groupedTrails = groupTrailsByDates(
+ enhancedCards,
+ speed === 'slow' || data.forceDay,
+ );
+
return {
...data,
tags: data.tags.tags,
- groupedTrails: groupTrailsByDates(
- enhancedCards,
- speed === 'slow' || data.forceDay,
- ),
+ groupedTrails: data.isAdFreeUser
+ ? groupedTrails
+ : injectMpuIntoGroupedTrails(groupedTrails, speed),
speed,
// Pagination information comes from the first tag
pagination:
diff --git a/dotcom-rendering/src/types/tagFront.ts b/dotcom-rendering/src/types/tagFront.ts
index d0cff6ac6ce..5b177db2949 100644
--- a/dotcom-rendering/src/types/tagFront.ts
+++ b/dotcom-rendering/src/types/tagFront.ts
@@ -1,7 +1,7 @@
import type { EditionId } from '../lib/edition';
-import type { GroupedTrails } from '../model/groupTrailsByDates';
+import type { Tuple } from '../lib/tuple';
import type { FooterType } from './footer';
-import type { FEFrontCard, FEFrontConfigType } from './front';
+import type { DCRFrontCard, FEFrontCard, FEFrontConfigType } from './front';
import type { FETagType } from './tag';
export interface FETagFrontType {
@@ -23,8 +23,36 @@ export interface FETagFrontType {
forceDay: boolean;
}
+/**
+ * Represents a set of trails grouped by their year, month & optionally day of publication.
+ */
+export interface GroupedTrailsBase {
+ year: number;
+ month: number;
+ day: number | undefined;
+}
+
+export interface GroupedTrails extends GroupedTrailsBase {
+ trails: DCRFrontCard[];
+}
+
+export interface GroupedTrailsFastMpu extends GroupedTrailsBase {
+ injected: true;
+ speed: 'fast';
+ // Trails must either be length of 2, 4, 6, 9
+ trails: Tuple;
+}
+export interface GroupedTrailsSlowMpu extends GroupedTrailsBase {
+ injected: true;
+ speed: 'slow';
+ // Trails must either be length of 2, 4, 5, 7
+ trails: Tuple;
+}
+
export interface DCRTagFrontType {
- groupedTrails: GroupedTrails[];
+ groupedTrails: Array<
+ GroupedTrails | GroupedTrailsFastMpu | GroupedTrailsSlowMpu
+ >;
nav: FENavType;
tags: FETagType[];
editionId: EditionId;