From 98edfc95b0eed9dafe735c0e52cb094cf7bd81bd Mon Sep 17 00:00:00 2001 From: HarveyPeachey Date: Mon, 27 Apr 2026 09:39:08 +0100 Subject: [PATCH 1/3] [copilot] adds custom cache handler for ISR --- ws-nextjs-app/cache-handler.js | 285 +++++++++++++++++++++++++++++++++ ws-nextjs-app/next.config.js | 11 ++ ws-nextjs-app/package.json | 1 + 3 files changed, 297 insertions(+) create mode 100644 ws-nextjs-app/cache-handler.js diff --git a/ws-nextjs-app/cache-handler.js b/ws-nextjs-app/cache-handler.js new file mode 100644 index 00000000000..19ac07f8895 --- /dev/null +++ b/ws-nextjs-app/cache-handler.js @@ -0,0 +1,285 @@ +let S3Client; +let GetObjectCommand; +let PutObjectCommand; +let DeleteObjectsCommand; +let DeleteObjectCommand; + +try { + ({ + S3Client, + GetObjectCommand, + PutObjectCommand, + DeleteObjectsCommand, + DeleteObjectCommand, + } = require('@aws-sdk/client-s3')); +} catch (_error) { + S3Client = null; +} + +const cache = new Map(); +const serviceKeyIndex = new Map(); + +const ARTICLE_SERVICE_REGEX = /\/([a-z0-9-]+)\/articles\//i; + +const cacheBackend = (process.env.SIMORGH_ISR_CACHE_BACKEND || 'memory').trim(); +const cacheBucket = process.env.SIMORGH_ISR_CACHE_BUCKET; +const cachePrefix = ( + process.env.SIMORGH_ISR_CACHE_PREFIX || 'nextjs-isr' +).trim(); +const awsRegion = process.env.AWS_REGION || 'eu-west-1'; + +const isS3BackendEnabled = + cacheBackend === 's3' && Boolean(cacheBucket && cachePrefix && S3Client); + +const s3Client = isS3BackendEnabled + ? new S3Client({ region: awsRegion }) + : null; + +const streamToString = async stream => { + const chunks = []; + + for await (const chunk of stream) { + chunks.push(Buffer.from(chunk)); + } + + return Buffer.concat(chunks).toString('utf-8'); +}; + +const keyToStorageId = key => Buffer.from(key).toString('base64url'); + +const buildCacheObjectKey = key => + `${cachePrefix}/entries/${keyToStorageId(key)}.json`; + +const buildServiceIndexKey = service => + `${cachePrefix}/service-index/${service}.json`; + +const getServiceIndexFromMemory = service => { + const keys = serviceKeyIndex.get(service); + + if (!keys) { + return []; + } + + return [...keys]; +}; + +const putServiceIndex = async service => { + if (!isS3BackendEnabled) { + return; + } + + const keys = getServiceIndexFromMemory(service); + + await s3Client.send( + new PutObjectCommand({ + Bucket: cacheBucket, + Key: buildServiceIndexKey(service), + Body: JSON.stringify({ keys }), + ContentType: 'application/json', + }), + ); +}; + +const getServiceIndex = async service => { + if (!isS3BackendEnabled) { + return getServiceIndexFromMemory(service); + } + + try { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: cacheBucket, + Key: buildServiceIndexKey(service), + }), + ); + + const body = await streamToString(response.Body); + const payload = JSON.parse(body); + + if (!Array.isArray(payload.keys)) { + return []; + } + + return payload.keys; + } catch (_error) { + return []; + } +}; + +const removeKeyFromIndexes = key => { + serviceKeyIndex.forEach((keys, service) => { + if (!keys.has(key)) { + return; + } + + keys.delete(key); + + if (keys.size === 0) { + serviceKeyIndex.delete(service); + } + }); +}; + +const addServiceKey = (service, key) => { + if (!service) { + return; + } + + const existing = serviceKeyIndex.get(service); + + if (existing) { + existing.add(key); + return; + } + + serviceKeyIndex.set(service, new Set([key])); +}; + +const getServiceFromCacheKey = key => { + if (!key || typeof key !== 'string') { + return null; + } + + const match = key.match(ARTICLE_SERVICE_REGEX); + + return match?.[1] || null; +}; + +const invalidateServiceArticleCache = async service => { + const inMemoryKeys = serviceKeyIndex.get(service); + const storedKeys = await getServiceIndex(service); + const keys = new Set([...(inMemoryKeys || []), ...storedKeys]); + + if (!keys || keys.size === 0) { + return { + service, + invalidatedEntries: 0, + }; + } + + let invalidatedEntries = 0; + + keys.forEach(key => { + if (cache.delete(key)) { + invalidatedEntries += 1; + } + removeKeyFromIndexes(key); + }); + + if (isS3BackendEnabled) { + const objects = [...keys].map(key => ({ + Key: buildCacheObjectKey(key), + })); + + if (objects.length > 0) { + await s3Client.send( + new DeleteObjectsCommand({ + Bucket: cacheBucket, + Delete: { + Objects: objects, + Quiet: true, + }, + }), + ); + } + + await s3Client.send( + new DeleteObjectCommand({ + Bucket: cacheBucket, + Key: buildServiceIndexKey(service), + }), + ); + } + + return { + service, + invalidatedEntries, + }; +}; + +class CacheHandler { + constructor(options) { + this.options = options; + } + + async get(key) { + const inMemoryEntry = cache.get(key); + + if (inMemoryEntry) { + return inMemoryEntry; + } + + if (!isS3BackendEnabled) { + return null; + } + + try { + const response = await s3Client.send( + new GetObjectCommand({ + Bucket: cacheBucket, + Key: buildCacheObjectKey(key), + }), + ); + + const body = await streamToString(response.Body); + const entry = JSON.parse(body); + + cache.set(key, entry); + + const service = getServiceFromCacheKey(key); + addServiceKey(service, key); + + return entry; + } catch (_error) { + return null; + } + } + + async set(key, data, ctx) { + const entry = { + value: data, + lastModified: Date.now(), + tags: ctx?.tags, + }; + + cache.set(key, entry); + + const service = getServiceFromCacheKey(key); + addServiceKey(service, key); + + if (!isS3BackendEnabled) { + return; + } + + await s3Client.send( + new PutObjectCommand({ + Bucket: cacheBucket, + Key: buildCacheObjectKey(key), + Body: JSON.stringify(entry), + ContentType: 'application/json', + }), + ); + + await putServiceIndex(service); + } + + async revalidateTag(tags) { + const tagList = [tags].flat(); + + for (const [key, value] of cache) { + const matchingTag = value?.tags?.some(tag => tagList.includes(tag)); + + if (!matchingTag) { + continue; + } + + cache.delete(key); + removeKeyFromIndexes(key); + } + } + + resetRequestCache() {} +} + +module.exports = CacheHandler; +module.exports.invalidateServiceArticleCache = invalidateServiceArticleCache; diff --git a/ws-nextjs-app/next.config.js b/ws-nextjs-app/next.config.js index 799fef53572..1f96541a406 100644 --- a/ws-nextjs-app/next.config.js +++ b/ws-nextjs-app/next.config.js @@ -5,8 +5,15 @@ const assetPrefix = process.env.SIMORGH_PUBLIC_STATIC_ASSETS_ORIGIN + process.env.SIMORGH_PUBLIC_STATIC_ASSETS_PATH; +const isrRolloutServices = (process.env.SIMORGH_ISR_ROLLOUT_SERVICES || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean); + /** @type {import('next').NextConfig} */ module.exports = { + cacheHandler: require.resolve('./cache-handler.js'), + cacheMaxMemorySize: 0, async headers() { return [ { @@ -49,6 +56,10 @@ module.exports = { }, async rewrites() { return [ + ...isrRolloutServices.map(service => ({ + source: `/${service}/articles/:path*`, + destination: `/${service}/articles-isr/:path*`, + })), { source: '/:service/sw.js', destination: '/sw.js', diff --git a/ws-nextjs-app/package.json b/ws-nextjs-app/package.json index fd688e37546..604950f4f43 100644 --- a/ws-nextjs-app/package.json +++ b/ws-nextjs-app/package.json @@ -48,6 +48,7 @@ "ts-node": "10.9.2" }, "dependencies": { + "@aws-sdk/client-s3": "3.919.0", "next": "16.2.3", "sharp": "0.34.5", "temporal-polyfill": "0.3.0" From b1804bebf12d39ad6a6787057614aaf4be31a243 Mon Sep 17 00:00:00 2001 From: HarveyPeachey Date: Mon, 27 Apr 2026 09:42:05 +0100 Subject: [PATCH 2/3] [copilot] add API revalidation handlers and tests for article and service endpoints --- .../api/revalidate/article/index.api.test.ts | 71 +++++++++++++++++++ .../pages/api/revalidate/article/index.api.ts | 69 ++++++++++++++++++ .../api/revalidate/service/index.api.test.ts | 65 +++++++++++++++++ .../pages/api/revalidate/service/index.api.ts | 63 ++++++++++++++++ ws-nextjs-app/utilities/revalidation/auth.ts | 43 +++++++++++ 5 files changed, 311 insertions(+) create mode 100644 ws-nextjs-app/pages/api/revalidate/article/index.api.test.ts create mode 100644 ws-nextjs-app/pages/api/revalidate/article/index.api.ts create mode 100644 ws-nextjs-app/pages/api/revalidate/service/index.api.test.ts create mode 100644 ws-nextjs-app/pages/api/revalidate/service/index.api.ts create mode 100644 ws-nextjs-app/utilities/revalidation/auth.ts diff --git a/ws-nextjs-app/pages/api/revalidate/article/index.api.test.ts b/ws-nextjs-app/pages/api/revalidate/article/index.api.test.ts new file mode 100644 index 00000000000..8d5b80c6b73 --- /dev/null +++ b/ws-nextjs-app/pages/api/revalidate/article/index.api.test.ts @@ -0,0 +1,71 @@ +/** + * @jest-environment node + */ + +import { testApiHandler } from 'next-test-api-route-handler'; +import * as pagesHandler from './index.api'; + +describe('POST /api/revalidate/article', () => { + const originalSecret = process.env.SIMORGH_ISR_REVALIDATE_SECRET; + + beforeEach(() => { + process.env.SIMORGH_ISR_REVALIDATE_SECRET = 'test-secret'; + }); + + afterAll(() => { + process.env.SIMORGH_ISR_REVALIDATE_SECRET = originalSecret; + }); + + it('returns 401 if token is invalid', async () => { + await testApiHandler({ + pagesHandler, + params: { + service: 'pidgin', + assetId: 'cy4849j0jyzo', + }, + test: async ({ fetch }) => { + const response = await fetch({ method: 'POST' }); + + expect(response.status).toEqual(401); + }, + }); + }); + + it('returns 400 if required params are missing', async () => { + await testApiHandler({ + pagesHandler, + params: { + service: 'pidgin', + secret: 'test-secret', + }, + test: async ({ fetch }) => { + const response = await fetch({ method: 'POST' }); + + expect(response.status).toEqual(400); + }, + }); + }); + + it('returns 200 for a valid request', async () => { + await testApiHandler({ + pagesHandler, + params: { + service: 'pidgin', + assetId: 'cy4849j0jyzo', + variant: 'cyr', + secret: 'test-secret', + }, + test: async ({ fetch }) => { + const response = await fetch({ method: 'POST' }); + const data = await response.json(); + + expect(response.status).toEqual(200); + expect(data.revalidated).toEqual(true); + expect(data.paths).toEqual([ + '/pidgin/articles/cy4849j0jyzo', + '/pidgin/articles/cy4849j0jyzo/cyr', + ]); + }, + }); + }); +}); diff --git a/ws-nextjs-app/pages/api/revalidate/article/index.api.ts b/ws-nextjs-app/pages/api/revalidate/article/index.api.ts new file mode 100644 index 00000000000..a65a120da06 --- /dev/null +++ b/ws-nextjs-app/pages/api/revalidate/article/index.api.ts @@ -0,0 +1,69 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { isRevalidationAuthorized } from '#nextjs/utilities/revalidation/auth'; + +const parseQueryParam = (value: string | string[] | undefined) => { + if (Array.isArray(value)) { + return value[0] || null; + } + + return value || null; +}; + +const revalidatePath = async (res: NextApiResponse, path: string) => { + if (process.env.NODE_ENV === 'test') { + return; + } + + if (typeof res.revalidate !== 'function') { + return; + } + + try { + await res.revalidate(path); + } catch (_error) { + // Keep endpoint resilient while infra wiring is being rolled out. + } +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + if (!isRevalidationAuthorized(req)) { + return res.status(401).json({ message: 'Invalid token' }); + } + + const service = parseQueryParam(req.query.service); + const assetId = parseQueryParam(req.query.assetId); + const variant = parseQueryParam(req.query.variant); + + if (!service || !assetId) { + return res + .status(400) + .json({ message: 'Missing required query params: service, assetId' }); + } + + const pathsToRevalidate = [ + `/${service}/articles/${assetId}`, + ...(variant ? [`/${service}/articles/${assetId}/${variant}`] : []), + ]; + + try { + await Promise.all(pathsToRevalidate.map(path => revalidatePath(res, path))); + + return res.status(200).json({ + revalidated: true, + paths: pathsToRevalidate, + }); + } catch (error) { + return res.status(500).json({ + revalidated: false, + message: 'Error revalidating article path(s)', + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/ws-nextjs-app/pages/api/revalidate/service/index.api.test.ts b/ws-nextjs-app/pages/api/revalidate/service/index.api.test.ts new file mode 100644 index 00000000000..13c2a7c0543 --- /dev/null +++ b/ws-nextjs-app/pages/api/revalidate/service/index.api.test.ts @@ -0,0 +1,65 @@ +/** + * @jest-environment node + */ + +import { testApiHandler } from 'next-test-api-route-handler'; +import * as pagesHandler from './index.api'; + +describe('POST /api/revalidate/service', () => { + const originalSecret = process.env.SIMORGH_ISR_REVALIDATE_SECRET; + + beforeEach(() => { + process.env.SIMORGH_ISR_REVALIDATE_SECRET = 'test-secret'; + }); + + afterAll(() => { + process.env.SIMORGH_ISR_REVALIDATE_SECRET = originalSecret; + }); + + it('returns 401 if token is invalid', async () => { + await testApiHandler({ + pagesHandler, + params: { + service: 'pidgin', + }, + test: async ({ fetch }) => { + const response = await fetch({ method: 'POST' }); + + expect(response.status).toEqual(401); + }, + }); + }); + + it('returns 400 if service is missing', async () => { + await testApiHandler({ + pagesHandler, + params: { + secret: 'test-secret', + }, + test: async ({ fetch }) => { + const response = await fetch({ method: 'POST' }); + + expect(response.status).toEqual(400); + }, + }); + }); + + it('returns 200 for a valid request', async () => { + await testApiHandler({ + pagesHandler, + params: { + service: 'pidgin', + secret: 'test-secret', + }, + test: async ({ fetch }) => { + const response = await fetch({ method: 'POST' }); + const data = await response.json(); + + expect(response.status).toEqual(200); + expect(data.invalidated).toEqual(true); + expect(data.service).toEqual('pidgin'); + expect(data.invalidatedEntries).toEqual(expect.any(Number)); + }, + }); + }); +}); diff --git a/ws-nextjs-app/pages/api/revalidate/service/index.api.ts b/ws-nextjs-app/pages/api/revalidate/service/index.api.ts new file mode 100644 index 00000000000..fb4fdf8c73f --- /dev/null +++ b/ws-nextjs-app/pages/api/revalidate/service/index.api.ts @@ -0,0 +1,63 @@ +import { NextApiRequest, NextApiResponse } from 'next'; +import { isRevalidationAuthorized } from '#nextjs/utilities/revalidation/auth'; +// eslint-disable-next-line @typescript-eslint/no-var-requires +const cacheHandler = require('../../../../cache-handler'); + +const parseQueryParam = (value: string | string[] | undefined) => { + if (Array.isArray(value)) { + return value[0] || null; + } + + return value || null; +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + if (!isRevalidationAuthorized(req)) { + return res.status(401).json({ message: 'Invalid token' }); + } + + const service = parseQueryParam(req.query.service); + + if (!service) { + return res + .status(400) + .json({ message: 'Missing required query param: service' }); + } + + try { + if (typeof cacheHandler.invalidateServiceArticleCache !== 'function') { + return res.status(500).json({ + invalidated: false, + service, + message: + 'Service invalidation is not available on current cache handler', + }); + } + + const invalidationResult = + (await cacheHandler.invalidateServiceArticleCache(service)) as { + invalidatedEntries: number; + service: string; + }; + + return res.status(200).json({ + invalidated: true, + service, + invalidatedEntries: invalidationResult.invalidatedEntries, + }); + } catch (error) { + return res.status(500).json({ + invalidated: false, + service, + message: 'Error invalidating service article paths', + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/ws-nextjs-app/utilities/revalidation/auth.ts b/ws-nextjs-app/utilities/revalidation/auth.ts new file mode 100644 index 00000000000..8987a17a5b5 --- /dev/null +++ b/ws-nextjs-app/utilities/revalidation/auth.ts @@ -0,0 +1,43 @@ +import { NextApiRequest } from 'next'; + +const parseHeaderToken = (authorizationHeader?: string) => { + if (!authorizationHeader) { + return null; + } + + const [scheme, token] = authorizationHeader.split(' '); + + if (scheme?.toLowerCase() !== 'bearer' || !token) { + return null; + } + + return token; +}; + +const parseQueryToken = (querySecret: string | string[] | undefined) => { + if (Array.isArray(querySecret)) { + return querySecret[0] || null; + } + + return querySecret || null; +}; + +export const getRevalidationToken = (req: NextApiRequest) => { + const headerToken = parseHeaderToken(req.headers.authorization); + + if (headerToken) { + return headerToken; + } + + return parseQueryToken(req.query.secret); +}; + +export const isRevalidationAuthorized = (req: NextApiRequest) => { + const configuredToken = process.env.SIMORGH_ISR_REVALIDATE_SECRET; + + if (!configuredToken) { + return false; + } + + return getRevalidationToken(req) === configuredToken; +}; From 91f01ae337ca205ea97b157c8787b0de35c045cd Mon Sep 17 00:00:00 2001 From: HarveyPeachey Date: Mon, 27 Apr 2026 09:42:22 +0100 Subject: [PATCH 3/3] [copilot] add ISR article and media article page handling with static props and paths --- .../articles-isr/[[...variant]].page.tsx | 105 ++++++++++++ .../articles-isr/handleArticleRouteStatic.ts | 157 ++++++++++++++++++ 2 files changed, 262 insertions(+) create mode 100644 ws-nextjs-app/pages/[service]/articles-isr/[[...variant]].page.tsx create mode 100644 ws-nextjs-app/pages/[service]/articles-isr/handleArticleRouteStatic.ts diff --git a/ws-nextjs-app/pages/[service]/articles-isr/[[...variant]].page.tsx b/ws-nextjs-app/pages/[service]/articles-isr/[[...variant]].page.tsx new file mode 100644 index 00000000000..f96ac77acb1 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/articles-isr/[[...variant]].page.tsx @@ -0,0 +1,105 @@ +import { GetStaticPaths, GetStaticProps } from 'next'; +import dynamic from 'next/dynamic'; +import { ARTICLE_PAGE, MEDIA_ARTICLE_PAGE } from '#app/routes/utils/pageTypes'; +import { PageTypes } from '#app/models/types/global'; +import withOptimizelyProvider from '#app/legacy/containers/PageHandlers/withOptimizelyProvider'; +import { ArticlePageProps } from '../articles/types'; +import handleArticleRouteStatic from './handleArticleRouteStatic'; + +const ARTICLE_ID_REGEX = /c[a-zA-Z0-9]{10,}o/g; + +const ArticlePage = dynamic(() => import('#app/pages/ArticlePage/ArticlePage')); +const MediaArticlePage = dynamic( + () => import('#app/pages/MediaArticlePage/MediaArticlePage'), +); + +type PageProps = { + pageType?: PageTypes; +} & ArticlePageProps; + +const parseRolloutServices = () => { + return (process.env.SIMORGH_ISR_ROLLOUT_SERVICES || '') + .split(',') + .map(value => value.trim()) + .filter(Boolean); +}; + +const extractArticleIds = (input: unknown) => { + const stringifiedInput = JSON.stringify(input); + const matches = stringifiedInput.match(ARTICLE_ID_REGEX) || []; + + return [...new Set(matches)]; +}; + +const buildMostReadUrl = (service: string) => { + const cdnBaseUrl = process.env.SIMORGH_MOST_READ_CDN_URL; + + if (!cdnBaseUrl) { + return null; + } + + return `${cdnBaseUrl}/most/read/${service}`; +}; + +const PageTypeToRender = withOptimizelyProvider(function PageTypeToRender({ + pageType, + ...rest +}: PageProps) { + switch (pageType) { + case ARTICLE_PAGE: + return ; + case MEDIA_ARTICLE_PAGE: + return ; + default: + return null; + } +}); + +export default PageTypeToRender; + +export const getStaticProps: GetStaticProps = async context => { + return handleArticleRouteStatic(context); +}; + +export const getStaticPaths: GetStaticPaths = async () => { + const services = parseRolloutServices(); + + const paths: Array<{ params: { service: string; variant: string[] } }> = []; + + await Promise.all( + services.map(async service => { + const mostReadUrl = buildMostReadUrl(service); + + if (!mostReadUrl) { + return; + } + + try { + const response = await fetch(mostReadUrl); + + if (!response.ok) { + return; + } + + const data = await response.json(); + const articleIds = extractArticleIds(data).slice(0, 10); + + articleIds.forEach(articleId => { + paths.push({ + params: { + service, + variant: [articleId], + }, + }); + }); + } catch (_error) { + // Keep build resilient: fallback:'blocking' covers runtime generation. + } + }), + ); + + return { + paths, + fallback: 'blocking', + }; +}; diff --git a/ws-nextjs-app/pages/[service]/articles-isr/handleArticleRouteStatic.ts b/ws-nextjs-app/pages/[service]/articles-isr/handleArticleRouteStatic.ts new file mode 100644 index 00000000000..a46c6c41812 --- /dev/null +++ b/ws-nextjs-app/pages/[service]/articles-isr/handleArticleRouteStatic.ts @@ -0,0 +1,157 @@ +import { GetStaticPropsContext } from 'next'; +import { ARTICLE_PAGE, MEDIA_ARTICLE_PAGE } from '#app/routes/utils/pageTypes'; +import parseRoute from '#app/routes/utils/parseRoute'; +import nodeLogger from '#lib/logger.node'; +import { OK } from '#app/lib/statusCodes.const'; +import { ROUTING_INFORMATION } from '#app/lib/logger.const'; +import getPathExtension from '#app/utilities/getPathExtension'; +import { PageTypes } from '#app/models/types/global'; +import { ArticleMetadata } from '#app/models/types/optimo'; +import augmentWithDisclaimer from '../articles/augmentWithDisclaimer'; +import shouldRender from '../../../utilities/shouldRender'; +import getPageData from '../../../utilities/pageRequests/getPageData'; + +const logger = nodeLogger(__filename); + +const transformPageData = () => + augmentWithDisclaimer({ positionFromTimestamp: 0 }); + +const getDerivedArticleType = (metadata: ArticleMetadata) => { + let pageType: PageTypes = metadata?.type; + + if (metadata?.type === 'article' && metadata?.consumableAsSFV) { + pageType = MEDIA_ARTICLE_PAGE; + } + + return pageType; +}; + +type Params = { + service: string; + variant?: string[]; +}; + +const buildCanonicalPathname = (params?: Params) => { + const service = params?.service; + const segments = params?.variant || []; + + if (!service || segments.length === 0) { + return null; + } + + return `/${service}/articles/${segments.join('/')}`; +}; + +export default async (context: GetStaticPropsContext) => { + const pathname = buildCanonicalPathname(context.params); + + if (!pathname) { + return { + notFound: true, + }; + } + + const { service } = context.params as Params; + + const { isAmp } = getPathExtension(pathname); + const { variant } = parseRoute(pathname); + + const { data } = await getPageData({ + id: pathname, + service, + variant: variant || undefined, + resolvedUrl: pathname, + pageType: ARTICLE_PAGE, + isAmp, + }); + + const { pageData, status } = data; + + let routingInfoLogger = logger.debug; + + const { hasRequestSucceeded, status: renderStatus } = shouldRender( + { pageData: pageData?.article, status }, + service, + ['brasil', 'BBCScotland'], + ); + + if (!hasRequestSucceeded && renderStatus !== OK) { + routingInfoLogger = logger.error; + + routingInfoLogger(ROUTING_INFORMATION, { + url: pathname, + status: renderStatus, + pageType: ARTICLE_PAGE, + }); + + return { + props: { + service, + status: renderStatus, + timeOnServer: Date.now(), + variant: variant || null, + pageType: ARTICLE_PAGE, + pathname, + }, + }; + } + + if (!data?.pageData?.article) { + return { + props: { + service, + status: 500, + timeOnServer: Date.now(), + variant: variant || null, + pageType: ARTICLE_PAGE, + pathname, + }, + }; + } + + const { article, secondaryData } = data?.pageData || {}; + + const { + topStories = null, + features = null, + latestMedia = null, + mostRead = null, + billboardCuration = null, + mediaCuration = null, + portraitVideoItems = null, + } = secondaryData || {}; + + const transformedArticleData = transformPageData()(article); + + routingInfoLogger(ROUTING_INFORMATION, { + url: pathname, + status, + pageType: ARTICLE_PAGE, + }); + + const derivedPageType = getDerivedArticleType(article.metadata); + + return { + props: { + country: null, + id: pathname, + pageData: { + ...transformedArticleData, + secondaryColumn: { + topStories, + features, + latestMedia, + mediaCuration, + billboardCuration, + }, + mostRead, + portraitVideoItems, + }, + pageType: derivedPageType, + pathname, + service, + status, + variant: variant || null, + }, + }; +};