Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6713641
WS-203 - MostRead on Next.js
amoore108 Mar 26, 2026
e822265
Add rewrite to support variant routes whilst migrating
amoore108 Mar 26, 2026
26e26f2
Update next.config.js
amoore108 Mar 26, 2026
d5d85ad
Pass `id` as `mostReadTopic`
amoore108 Mar 26, 2026
c86a502
Update 5xx error
amoore108 Mar 26, 2026
65dcfae
Update [[...variant]].page.tsx
amoore108 Mar 26, 2026
ceaace6
Move `id` out of `context.query`
amoore108 Mar 26, 2026
8dc950e
Add gSSP tests
amoore108 Mar 26, 2026
0261ddb
Update [[...variant]].page.tsx
amoore108 Mar 26, 2026
fd225cb
Update `constructPageFetchUrl` to account for `mostReadTopic` ID
amoore108 Mar 26, 2026
b336c88
Move Most Read Topic into local data `/topics` folder
amoore108 Mar 26, 2026
b190e91
Move script switch e2es out of Express app
amoore108 Mar 26, 2026
dfdb300
Update index.cy.ts
amoore108 Mar 26, 2026
ce5c278
Revert "Update index.cy.ts"
amoore108 Mar 26, 2026
26380ef
Revert "Move script switch e2es out of Express app"
amoore108 Mar 26, 2026
6373264
Revert "Move Most Read Topic into local data `/topics` folder"
amoore108 Mar 26, 2026
79a8812
Revert "Update `constructPageFetchUrl` to account for `mostReadTopic`…
amoore108 Mar 26, 2026
207a72e
Fix `constructPageFetchUrl`
amoore108 Mar 26, 2026
a489e03
Fix import
amoore108 Mar 26, 2026
e2158e5
Fix `constructPageFetchUrl` check + add tests
amoore108 Mar 26, 2026
134b11f
Remove unneeded import
amoore108 Mar 26, 2026
9a9645d
Remove `*` wildcard from rewrite
amoore108 Mar 26, 2026
02be6f2
Normalise `id` path check
amoore108 Mar 26, 2026
9d62f36
Fix leading slash in tests
amoore108 Mar 26, 2026
630544d
Merge branch 'latest' into WS-203-most-read-nextjs
amoore108 Mar 26, 2026
b5a02dd
Merge branch 'latest' into WS-203-most-read-nextjs
eagerterrier Mar 31, 2026
c995f73
Update `cache-control` header to existing Most Read page
amoore108 Mar 31, 2026
b75053d
Support `.lite` extension in route
amoore108 Mar 31, 2026
fd0a2a3
Merge branch 'latest' into WS-203-most-read-nextjs
amoore108 Mar 31, 2026
223339a
Merge branch 'latest' into WS-203-most-read-nextjs
amoore108 Mar 31, 2026
39eb95f
Merge branch 'latest' into WS-203-most-read-nextjs
amoore108 Mar 31, 2026
1fd7828
Add `MOST_READ_PAGE` to `derivePageType` function
amoore108 Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
711 changes: 711 additions & 0 deletions data/pidgin/topics/mostReadTopic.json
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, just wanted to ask where this fixture data came from? Was it FABL?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea we added some logic in the API to return a topic page if id='mostReadTopic' is being requested: https://github.com/bbc/fabl-modules/pull/11664

Just seemed to make sense to re-use the Topics API since it handles all the fetching and data transformation for us already.

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/app/routes/utils/constructPageFetchUrl/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ describe('constructPageFetchUrl', () => {
${LIVE_TV_PAGE} | ${'dari'} | ${null} | ${'local'} | ${'/dari/watch/bbc_afghan_tv/live'} | ${'http://localhost/api/local/dari/watch/bbc_afghan_tv/live'}
${LIVE_TV_PAGE} | ${'dari'} | ${null} | ${'test'} | ${'/dari/watch/bbc_afghan_tv/live'} | ${'https://mock-bff-path/?id=bbc_afghan_tv&service=dari&pageType=liveTV&serviceEnv=test'}
${LIVE_TV_PAGE} | ${'dari'} | ${null} | ${'live'} | ${'/dari/watch/bbc_afghan_tv/live'} | ${'https://mock-bff-path/?id=bbc_afghan_tv&service=dari&pageType=liveTV&serviceEnv=live'}
${TOPIC_PAGE} | ${'pidgin'} | ${null} | ${'test'} | ${'mostReadTopic'} | ${'https://mock-bff-path/?id=mostReadTopic&service=pidgin&pageType=topic&serviceEnv=test'}
${TOPIC_PAGE} | ${'pidgin'} | ${null} | ${'live'} | ${'mostReadTopic'} | ${'https://mock-bff-path/?id=mostReadTopic&service=pidgin&pageType=topic&serviceEnv=live'}
`(
`on $environment environment, should return $expected when path is $pathname, pageType is $pageType, service is $serviceOverride and variant is $variant`,
({
Expand Down
5 changes: 5 additions & 0 deletions src/app/routes/utils/constructPageFetchUrl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ const getId = ({ pageType, service, variant }: GetIdProps) => {

case TOPIC_PAGE:
getIdFunction = (path: string) => {
const normalizedPath = removeLeadingSlash(path);

// Special case for Most Read pages which are actually Topic pages
if (normalizedPath === 'mostReadTopic') return normalizedPath;

return getTipoId(path);
};
break;
Expand Down
6 changes: 6 additions & 0 deletions ws-nextjs-app/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ module.exports = {
source: '/:service/og/:id',
destination: '/api/:service/og/:id',
},
// TODO: This can be removed once we redirect variant paths to have variant at the end of the path,
// e.g. /serbian/cyr/popular/read -> /serbian/popular/read/cyr
{
source: '/:service/:variant/popular/read',
destination: '/:service/popular/read/:variant',
},
];
},
assetPrefix,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { GetServerSidePropsContext } from 'next';
import { MOST_READ_PAGE } from '#app/routes/utils/pageTypes';
import pidginMostReadTopicFixture from '#data/pidgin/topics/mostReadTopic.json';
import * as getPageDataModule from '../../../../utilities/pageRequests/getPageData';
import { getServerSideProps as handleMostReadRoute } from './[[...variant]].page';

jest.mock('../../../../utilities/pageRequests/getPageData');

describe('handleMostReadRoute', () => {
const mockSetHeader = jest.fn();
const mockGetServerSidePropsContext = {
req: {
headers: {},
} as unknown as GetServerSidePropsContext['req'],
res: {
setHeader: mockSetHeader,
removeHeader: jest.fn(),
on: jest.fn(),
} as unknown as GetServerSidePropsContext['res'],
resolvedUrl: '/pidgin/popular/read',
query: { service: 'pidgin' },
} satisfies GetServerSidePropsContext;

beforeEach(() => {
jest.restoreAllMocks();
jest.clearAllMocks();
jest.spyOn(getPageDataModule, 'default').mockResolvedValue({
data: {
pageData: pidginMostReadTopicFixture.data,
status: 200,
},
});
});

it('returns expected props if data fetch succeeds', async () => {
jest.spyOn(Date, 'now').mockImplementation(() => 1234567890000);

const result = await handleMostReadRoute(mockGetServerSidePropsContext);

expect(result.props.status).toEqual(200);
expect(result.props.pageType).toEqual(MOST_READ_PAGE);
});

it('returns error props if data fetch returns 500', async () => {
jest.spyOn(getPageDataModule, 'default').mockResolvedValue({
data: {
pageData: pidginMostReadTopicFixture.data,
status: 500,
},
});

jest.spyOn(Date, 'now').mockImplementation(() => 1234567890000);

const result = await handleMostReadRoute(mockGetServerSidePropsContext);

expect(result).toEqual({
props: expect.objectContaining({
status: 500,
pageType: MOST_READ_PAGE,
pathname: '/pidgin/popular/read',
}),
});
});

it('returns error props if data fetch returns 404', async () => {
jest.spyOn(getPageDataModule, 'default').mockResolvedValue({
data: {
pageData: pidginMostReadTopicFixture.data,
status: 404,
},
});

jest.spyOn(Date, 'now').mockImplementation(() => 1234567890000);

const result = await handleMostReadRoute(mockGetServerSidePropsContext);

expect(result).toEqual({
props: expect.objectContaining({
status: 404,
pageType: MOST_READ_PAGE,
pathname: '/pidgin/popular/read',
}),
});
});

it('throws if pageData is missing', async () => {
jest.spyOn(getPageDataModule, 'default').mockResolvedValue({
data: { pageData: null, status: 200 },
});

await expect(
handleMostReadRoute(mockGetServerSidePropsContext),
).rejects.toThrow('MostReadPage data is malformed');
});

it('sets correct cache-control header', async () => {
await handleMostReadRoute(mockGetServerSidePropsContext);

expect(mockSetHeader).toHaveBeenCalledWith(
'Cache-Control',
expect.stringContaining('max-age=30'),
);
});
});
117 changes: 117 additions & 0 deletions ws-nextjs-app/pages/[service]/popular/[read]/[[...variant]].page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { GetServerSidePropsContext } from 'next';
import dynamic from 'next/dynamic';
import { TOPIC_PAGE, MOST_READ_PAGE } from '#app/routes/utils/pageTypes';
import nodeLogger from '#lib/logger.node';
import logResponseTime from '#server/utilities/logResponseTime';
import { ROUTING_INFORMATION } from '#app/lib/logger.const';
import { OK } from '#app/lib/statusCodes.const';
import PageDataParams from '#app/models/types/pageDataParams';
import deriveVariant from '#nextjs/utilities/deriveVariant';
import isTest from '#app/lib/utilities/isTest';
import handleError from '#app/routes/utils/handleError';
import getPageData from '#nextjs/utilities/pageRequests/getPageData';

const MostReadAsTopicPage = dynamic(
() => import('#app/pages/TopicPage/TopicPage'),
);

const logger = nodeLogger(__filename);

export const getServerSideProps = async (
context: GetServerSidePropsContext,
) => {
const { resolvedUrl } = context;

logResponseTime({ path: resolvedUrl }, context.res, () => null);

const {
service,
variant: variantFromUrl,
renderer_env: rendererEnvFromQuery,
page,
} = context.query as PageDataParams;

const variant = deriveVariant(variantFromUrl);

const rendererEnv =
isTest() && !rendererEnvFromQuery ? 'live' : rendererEnvFromQuery;

const resolvedUrlWithoutQuery = resolvedUrl.split('?')?.[0];

const id = 'mostReadTopic';

const { data } = await getPageData({
id,
page,
service,
variant,
rendererEnv,
resolvedUrl: resolvedUrlWithoutQuery,
pageType: TOPIC_PAGE,
});

const { status } = data;

context.res.statusCode = status;

let routingInfoLogger = logger.debug;

if (status !== OK) {
routingInfoLogger = logger.error;

routingInfoLogger(ROUTING_INFORMATION, {
url: resolvedUrlWithoutQuery,
status,
pageType: MOST_READ_PAGE,
});

return {
props: {
service,
status,
timeOnServer: Date.now(),
variant,
pageType: MOST_READ_PAGE,
pathname: resolvedUrlWithoutQuery,
},
};
}

if (!data?.pageData) {
throw handleError('MostReadPage data is malformed', 500);
}

context.res.setHeader(
'Cache-Control',
'public, stale-if-error=300, stale-while-revalidate=120, max-age=30',
);

routingInfoLogger(ROUTING_INFORMATION, {
url: resolvedUrlWithoutQuery,
status: data.status,
pageType: MOST_READ_PAGE,
});

return {
props: {
error: data?.error || null,
id,
page: page || null,
pageData: {
...data.pageData,
metadata: {
...data.pageData.metadata,
type: MOST_READ_PAGE,
},
},
pageType: MOST_READ_PAGE,
pathname: resolvedUrlWithoutQuery,
service,
status: data.status,
timeOnServer: Date.now(),
variant,
},
};
};

export default MostReadAsTopicPage;
19 changes: 13 additions & 6 deletions ws-nextjs-app/utilities/derivePageType/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TOPIC_PAGE,
AUDIO_PAGE,
TV_PAGE,
MOST_READ_PAGE,
} from '#app/routes/utils/pageTypes';
import derivePageType from '.';

Expand Down Expand Up @@ -91,15 +92,21 @@ describe('derivePageType', () => {
expect(result).toEqual(TV_PAGE);
});

it('should return Unknown if pathname does not include live or send', () => {
const pathname = '/pidgin/xxxxxxxxx';
const result = derivePageType(pathname);
expect(result).toEqual(UNKNOWN_PAGE);
});

it("should return TOPIC_PAGE if pathname includes 'topic'", () => {
const pathname = '/pidgin/topics/c95y35941vrt';
const result = derivePageType(pathname);
expect(result).toEqual(TOPIC_PAGE);
});

it('should return MOST_READ_PAGE if pathname includes popular/read', () => {
const pathname = '/pidgin/popular/read';
const result = derivePageType(pathname);
expect(result).toEqual(MOST_READ_PAGE);
});

it('should return Unknown if pathname does not include live or send', () => {
const pathname = '/pidgin/xxxxxxxxx';
const result = derivePageType(pathname);
expect(result).toEqual(UNKNOWN_PAGE);
});
});
2 changes: 2 additions & 0 deletions ws-nextjs-app/utilities/derivePageType/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
TOPIC_PAGE,
AUDIO_PAGE,
TV_PAGE,
MOST_READ_PAGE,
} from '#app/routes/utils/pageTypes';
import {
isOptimoIdCheck,
Expand Down Expand Up @@ -60,6 +61,7 @@ export default function derivePageType(pathname: string): PageTypes {
if (sanitisedPathname.includes('av-embeds')) return AV_EMBEDS;
if (sanitisedPathname.includes('downloads')) return DOWNLOADS_PAGE;
if (sanitisedPathname.includes('topics')) return TOPIC_PAGE;
if (sanitisedPathname.includes('popular/read')) return MOST_READ_PAGE;
if (isOnDemandAudioPath(sanitisedPathname)) return AUDIO_PAGE;
if (isOnDemandTvPath(sanitisedPathname)) return TV_PAGE;
if (isOptimoIdCheck(sanitisedPathname)) return ARTICLE_PAGE;
Expand Down
Loading