From 5ba8cd1f05366de5dfaf1d64608307947222f7aa Mon Sep 17 00:00:00 2001 From: Ravi <7014230+arelra@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:04:26 +0100 Subject: [PATCH 1/6] DCAR Sites - Shell --- .../scripts/jsonSchema/schema.mjs | 5 + dotcom-rendering/src/components/ShellPage.tsx | 223 +++++ dotcom-rendering/src/frontend/feShell.ts | 16 + .../src/frontend/schemas/feShell.json | 858 ++++++++++++++++++ dotcom-rendering/src/lib/canRenderAds.ts | 3 +- dotcom-rendering/src/model/validate.ts | 30 + .../src/server/handler.article.web.ts | 10 + .../src/server/handler.shell.web.ts | 11 + .../src/server/lib/get-content-from-url.ts | 13 + .../src/server/render.shell.web.tsx | 121 +++ dotcom-rendering/src/server/server.dev.ts | 3 + 11 files changed, 1292 insertions(+), 1 deletion(-) create mode 100644 dotcom-rendering/src/components/ShellPage.tsx create mode 100644 dotcom-rendering/src/frontend/feShell.ts create mode 100644 dotcom-rendering/src/frontend/schemas/feShell.json create mode 100644 dotcom-rendering/src/server/handler.shell.web.ts create mode 100644 dotcom-rendering/src/server/render.shell.web.tsx diff --git a/dotcom-rendering/scripts/jsonSchema/schema.mjs b/dotcom-rendering/scripts/jsonSchema/schema.mjs index beec02248b4..5394cda43c4 100644 --- a/dotcom-rendering/scripts/jsonSchema/schema.mjs +++ b/dotcom-rendering/scripts/jsonSchema/schema.mjs @@ -19,6 +19,7 @@ const program = TJS.getProgramFromFiles( path.resolve(`${root}/src/frontend/feFootballMatchListPage.ts`), path.resolve(`${root}/src/frontend/feFootballTablesPage.ts`), path.resolve(`${root}/src/frontend/feFootballMatchInfoPage.ts`), + path.resolve(`${root}/src/frontend/feShell.ts`), ], { skipLibCheck: true, @@ -77,6 +78,10 @@ const schemas = [ typeName: 'FEFootballMatchInfoPage', file: `${root}/src/frontend/schemas/feFootballMatchInfoPage.json`, }, + { + typeName: 'FEShell', + file: `${root}/src/frontend/schemas/feShell.json`, + }, ]; /** diff --git a/dotcom-rendering/src/components/ShellPage.tsx b/dotcom-rendering/src/components/ShellPage.tsx new file mode 100644 index 00000000000..30f4b6d5209 --- /dev/null +++ b/dotcom-rendering/src/components/ShellPage.tsx @@ -0,0 +1,223 @@ +import { Global } from '@emotion/react'; +import { palette } from '@guardian/source/foundations'; +import { StrictMode } from 'react'; +import { AdSlot } from '../components/AdSlot.web'; +import type { FEShell } from '../frontend/feShell'; +import { BannerWrapper, Stuck } from '../layouts/lib/stickiness'; +import { buildAdTargeting } from '../lib/ad-targeting'; +import { ArticleDesign, ArticleDisplay, Pillar } from '../lib/articleFormat'; +import { canRenderAds } from '../lib/canRenderAds'; +import { rootStyles } from '../lib/rootStyles'; +import type { NavType } from '../model/extract-nav'; +import { AlreadyVisited } from './AlreadyVisited.island'; +import { useConfig } from './ConfigContext'; +import { DarkModeMessage } from './DarkModeMessage'; +import { FocusStyles } from './FocusStyles.island'; +import { Footer } from './Footer'; +import { HeaderAdSlot } from './HeaderAdSlot'; +import { Island } from './Island'; +import { Masthead } from './Masthead/Masthead'; +import { Metrics } from './Metrics.island'; +import { Section } from './Section'; +import { SetABTests } from './SetABTests.island'; +import { SetAdTargeting } from './SetAdTargeting.island'; +import { SkipTo } from './SkipTo'; +import { StickyBottomBanner } from './StickyBottomBanner.island'; +import { SubNav } from './SubNav.island'; + +type Props = { + feShell: FEShell; + nav: NavType; + renderingTarget: 'Web' | 'Apps'; +}; + +export const ShellPage = (props: Props) => { + const { feShell, nav, renderingTarget } = props; + + const isWeb = renderingTarget === 'Web'; + const isApps = renderingTarget === 'Apps'; + + const renderAds = canRenderAds(feShell); + const config = feShell.config; + + const adTargeting = buildAdTargeting({ + isAdFreeUser: feShell.isAdFreeUser, + isSensitive: config.isSensitive, + edition: config.edition, + section: config.section, + sharedAdTargeting: config.sharedAdTargeting, + adUnit: config.adUnit, + }); + + /* We use this as our "base" or default format */ + const format = { + display: ArticleDisplay.Standard, + design: ArticleDesign.Standard, + theme: Pillar.News, + }; + + const { darkModeAvailable } = useConfig(); + + return ( + + + {isWeb && ( + <> + + + + )} + + + + {isWeb && ( + <> + + + + + + + + + + + + + {darkModeAvailable && ( + + Dark mode is a work-in-progress. +
+ You can{' '} + + opt out anytime + {' '} + if anything is unreadable or odd. +
+ )} + + )} + {/* layout */} + {isWeb && ( +
+ {renderAds && ( + +
+ +
+
+ )} + + +
+ )} + {isWeb && renderAds && config.hasSurveyAd && ( + + )} + +
Hello DCAR Sites shell!
+ + {isWeb && ( + <> + {props.nav.subNavSections && ( +
+ + + +
+ )} + +
+
+
+ + + + + + + )} + {isApps && ( + <> +
Coming soon. Thanks for dropping by.
+ + )} +
+ ); +}; diff --git a/dotcom-rendering/src/frontend/feShell.ts b/dotcom-rendering/src/frontend/feShell.ts new file mode 100644 index 00000000000..25c82ea363b --- /dev/null +++ b/dotcom-rendering/src/frontend/feShell.ts @@ -0,0 +1,16 @@ +import type { EditionId } from '../lib/edition'; +import type { ConfigType } from '../types/config'; +import type { FooterType } from '../types/footer'; +import type { FENavType } from '../types/frontend'; + +export type FEShell = { + canonicalUrl: string; + config: ConfigType; + contributionsServiceUrl: string; + editionId: EditionId; + guardianBaseURL: string; + isAdFreeUser: boolean; + nav: FENavType; + pageFooter: FooterType; + pageId: string; +}; diff --git a/dotcom-rendering/src/frontend/schemas/feShell.json b/dotcom-rendering/src/frontend/schemas/feShell.json new file mode 100644 index 00000000000..fb1955ed46d --- /dev/null +++ b/dotcom-rendering/src/frontend/schemas/feShell.json @@ -0,0 +1,858 @@ +{ + "type": "object", + "properties": { + "canonicalUrl": { + "type": "string" + }, + "config": { + "$ref": "#/definitions/ConfigType" + }, + "contributionsServiceUrl": { + "type": "string" + }, + "editionId": { + "$ref": "#/definitions/EditionId" + }, + "guardianBaseURL": { + "type": "string" + }, + "isAdFreeUser": { + "type": "boolean" + }, + "nav": { + "$ref": "#/definitions/FENavType" + }, + "pageFooter": { + "$ref": "#/definitions/FooterType" + }, + "pageId": { + "type": "string" + } + }, + "required": [ + "canonicalUrl", + "config", + "contributionsServiceUrl", + "editionId", + "guardianBaseURL", + "isAdFreeUser", + "nav", + "pageFooter", + "pageId" + ], + "definitions": { + "ConfigType": { + "description": "the config model will contain useful app/site\nlevel data. Although currently derived from the config model\nconstructed in frontend and passed to dotcom-rendering\nthis data could eventually be defined in dotcom-rendering", + "type": "object", + "properties": { + "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" + }, + "adUnit": { + "type": "string" + }, + "ajaxUrl": { + "type": "string" + }, + "brazeApiKey": { + "type": "string" + }, + "commercialBundleUrl": { + "type": "string" + }, + "dcrCouldRender": { + "type": "boolean" + }, + "dcrSentryDsn": { + "type": "string" + }, + "dfpAccountId": { + "type": "string" + }, + "discussionApiClientHeader": { + "type": "string" + }, + "discussionApiUrl": { + "type": "string" + }, + "discussionD2Uid": { + "type": "string" + }, + "edition": { + "$ref": "#/definitions/EditionId" + }, + "frontendAssetsFullURL": { + "type": "string" + }, + "googleRecaptchaSiteKey": { + "type": "string" + }, + "googleRecaptchaSiteKeyVisible": { + "type": "string" + }, + "googletagUrl": { + "type": "string" + }, + "hasPageSkin": { + "type": "boolean" + }, + "host": { + "type": "string" + }, + "idApiUrl": { + "type": "string" + }, + "idUrl": { + "type": "string" + }, + "ipsosTag": { + "type": "string" + }, + "isDev": { + "type": "boolean" + }, + "isLive": { + "type": "boolean" + }, + "isLiveBlog": { + "type": "boolean" + }, + "isPaidContent": { + "type": "boolean" + }, + "isPhotoEssay": { + "type": "boolean" + }, + "isPreview": { + "type": "boolean" + }, + "isSensitive": { + "type": "boolean" + }, + "keywordIds": { + "type": "string" + }, + "mmaUrl": { + "type": "string" + }, + "references": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "revisionNumber": { + "type": "string" + }, + "section": { + "type": "string" + }, + "sentryHost": { + "type": "string" + }, + "sentryPublicApiKey": { + "type": "string" + }, + "serverSideABTests": { + "$ref": "#/definitions/Record" + }, + "sharedAdTargeting": { + "$ref": "#/definitions/SharedAdTargeting" + }, + "shortUrlId": { + "type": "string" + }, + "shouldHideReaderRevenue": { + "type": "boolean" + }, + "showRelatedContent": { + "type": "boolean" + }, + "source": { + "type": "string" + }, + "stage": { + "$ref": "#/definitions/StageType" + }, + "switches": { + "$ref": "#/definitions/Switches" + }, + "userBenefitsApiUrl": { + "type": "string" + }, + "videoDuration": { + "type": "number" + }, + "pageId": { + "type": "string" + }, + "webPublicationDate": { + "type": "number" + }, + "headline": { + "type": "string" + }, + "author": { + "type": "string" + }, + "keywords": { + "type": "string" + }, + "series": { + "type": "string" + }, + "toneIds": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "ampIframeUrl": { + "type": "string" + }, + "hasLiveBlogTopAd": { + "type": "boolean" + }, + "hasSurveyAd": { + "type": "boolean" + }, + "a9PublisherId": { + "type": "string" + }, + "allowUserGeneratedContent": { + "type": "boolean" + }, + "assetsPath": { + "type": "string" + }, + "atoms": { + "type": "array", + "items": { + "type": "string" + } + }, + "atomTypes": { + "type": "object", + "properties": { + "audio": { + "type": "boolean" + }, + "callToAction": { + "type": "boolean" + }, + "chart": { + "type": "boolean" + }, + "commonsdivision": { + "type": "boolean" + }, + "explainer": { + "type": "boolean" + }, + "guide": { + "type": "boolean" + }, + "interactive": { + "type": "boolean" + }, + "media": { + "type": "boolean" + }, + "profile": { + "type": "boolean" + }, + "qanda": { + "type": "boolean" + }, + "quizz": { + "type": "boolean" + }, + "review": { + "type": "boolean" + }, + "timeline": { + "type": "boolean" + } + } + }, + "authorIds": { + "type": "string" + }, + "avatarApiUrl": { + "type": "string" + }, + "avatarImagesUrl": { + "type": "string" + }, + "beaconUrl": { + "type": "string" + }, + "blogIds": { + "type": "string" + }, + "blogs": { + "type": "string" + }, + "buildNumber": { + "type": "string" + }, + "byline": { + "type": "string" + }, + "calloutsUrl": { + "type": "string" + }, + "campaigns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "rules": { + "type": "array", + "items": {} + }, + "priority": { + "type": "number" + }, + "displayOnSensitive": { + "type": "boolean" + }, + "fields": { + "type": "object", + "properties": { + "campaignId": { + "type": "string" + }, + "_type": { + "type": "string" + } + } + } + } + } + }, + "cardStyle": { + "type": "string" + }, + "commentable": { + "type": "boolean" + }, + "commissioningDesks": { + "type": "string" + }, + "contentId": { + "type": "string" + }, + "contributorBio": { + "type": "string" + }, + "dfpAdUnitRoot": { + "type": "string" + }, + "dfpHost": { + "type": "string" + }, + "disableStickyTopBanner": { + "type": "boolean" + }, + "externalEmbedHost": { + "type": "string" + }, + "facebookIaAdUnitRoot": { + "type": "string" + }, + "fbAppId": { + "type": "string" + }, + "forecastsapiurl": { + "type": "string" + }, + "frontendSentryDsn": { + "type": "string" + }, + "googleSearchId": { + "type": "string" + }, + "googleSearchUrl": { + "type": "string" + }, + "googletagJsUrl": { + "type": "string" + }, + "hasMultipleVideosInPage": { + "type": "boolean" + }, + "hasShowcaseMainElement": { + "type": "boolean" + }, + "hasYouTubeAtom": { + "type": "boolean" + }, + "idOAuthUrl": { + "type": "string" + }, + "idWebAppUrl": { + "type": "string" + }, + "inBodyExternalLinkCount": { + "type": "number" + }, + "inBodyInternalLinkCount": { + "type": "number" + }, + "isAdFree": { + "type": "boolean" + }, + "isColumn": { + "type": "boolean" + }, + "isContent": { + "type": "boolean" + }, + "isFront": { + "type": "boolean" + }, + "isHosted": { + "type": "boolean" + }, + "isImmersive": { + "type": "boolean" + }, + "isNumberedList": { + "type": "boolean" + }, + "isProd": { + "type": "boolean" + }, + "isSplash": { + "type": "boolean" + }, + "lightboxImages": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "headline": { + "type": "string" + }, + "shouldHideAdverts": { + "type": "boolean" + }, + "standfirst": { + "type": "string" + }, + "images": { + "type": "array", + "items": { + "type": "object", + "properties": { + "caption": { + "type": "string" + }, + "credit": { + "type": "string" + }, + "displayCredit": { + "type": "boolean" + }, + "src": { + "type": "string" + }, + "srcsets": { + "type": "string" + }, + "sizes": { + "type": "string" + }, + "ratio": { + "type": "number" + }, + "role": { + "type": "string" + }, + "parentContentId": { + "type": "string" + }, + "id": { + "type": "string" + } + } + } + } + } + }, + "locationapiurl": { + "type": "string" + }, + "membershipAccess": { + "type": "string" + }, + "membershipUrl": { + "type": "string" + }, + "mobileAppsAdUnitRoot": { + "type": "string" + }, + "nonKeywordTagIds": { + "type": "string" + }, + "omnitureAccount": { + "type": "string" + }, + "omnitureAmpAccount": { + "type": "string" + }, + "onwardWebSocket": { + "type": "string" + }, + "ophanEmbedJsUrl": { + "type": "string" + }, + "ophanJsUrl": { + "type": "string" + }, + "optimizeEpicUrl": { + "type": "string" + }, + "pageCode": { + "type": "string" + }, + "pbIndexSites": {}, + "pillar": { + "type": "string" + }, + "plistaPublicApiKey": { + "type": "string" + }, + "productionOffice": { + "type": "string" + }, + "publication": { + "type": "string" + }, + "requiresMembershipAccess": { + "type": "boolean" + }, + "richLink": { + "type": "string" + }, + "sectionName": { + "type": "string" + }, + "shortUrl": { + "type": "string" + }, + "shouldHideAdverts": { + "type": "boolean" + }, + "sponsorshipType": { + "type": "string" + }, + "stripePublicToken": { + "type": "string" + }, + "supportUrl": { + "type": "string" + }, + "thirdPartyAppsAccount": { + "type": "string" + }, + "thumbnail": { + "type": "string" + }, + "tones": { + "type": "string" + }, + "userAttributesApiUrl": { + "type": "string" + }, + "weatherapiurl": { + "type": "string" + }, + "webTitle": { + "type": "string" + }, + "wordCount": { + "type": "number" + } + }, + "required": [ + "abTests", + "adUnit", + "ajaxUrl", + "ampIframeUrl", + "commercialBundleUrl", + "contentType", + "dcrSentryDsn", + "dfpAccountId", + "discussionApiClientHeader", + "discussionApiUrl", + "discussionD2Uid", + "edition", + "frontendAssetsFullURL", + "googletagUrl", + "idApiUrl", + "isSensitive", + "keywordIds", + "pageId", + "revisionNumber", + "section", + "sentryHost", + "sentryPublicApiKey", + "serverSideABTests", + "sharedAdTargeting", + "shortUrlId", + "showRelatedContent", + "stage", + "switches" + ] + }, + "EditionId": { + "enum": [ + "AU", + "EUR", + "INT", + "UK", + "US" + ], + "type": "string" + }, + "Record": { + "type": "object" + }, + "SharedAdTargeting": { + "type": "object" + }, + "StageType": { + "enum": [ + "CODE", + "DEV", + "PROD" + ], + "type": "string" + }, + "Switches": { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + "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": { + "description": "Data types for the API request bodies from clients that require transformation before internal use.\nWhere data types are coming from Frontend we try to use the 'FE' prefix.\nPrior to this we used 'CAPI' as a prefix which wasn't entirely accurate, and some data structures never received the prefix, meaning some are still missing it.", + "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" + ] + }, + "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/lib/canRenderAds.ts b/dotcom-rendering/src/lib/canRenderAds.ts index a59debd4b8a..c0c286563b8 100644 --- a/dotcom-rendering/src/lib/canRenderAds.ts +++ b/dotcom-rendering/src/lib/canRenderAds.ts @@ -1,3 +1,4 @@ +import type { FEShell } from '../frontend/feShell'; import type { SportDataPage } from '../sportDataPage'; import type { ArticleDeprecated } from '../types/article'; import type { Front } from '../types/front'; @@ -8,7 +9,7 @@ import type { TagPage } from '../types/tagPage'; * prevent ads from being displayed. */ export const canRenderAds = ( - pageData: ArticleDeprecated | Front | TagPage | SportDataPage, + pageData: ArticleDeprecated | Front | TagPage | SportDataPage | FEShell, ): boolean => { if (pageData.isAdFreeUser) { return false; diff --git a/dotcom-rendering/src/model/validate.ts b/dotcom-rendering/src/model/validate.ts index 89791de1f3d..7602dced544 100644 --- a/dotcom-rendering/src/model/validate.ts +++ b/dotcom-rendering/src/model/validate.ts @@ -8,6 +8,7 @@ import type { FEFootballMatchInfoPage } from '../frontend/feFootballMatchInfoPag import type { FEFootballMatchListPage } from '../frontend/feFootballMatchListPage'; import type { FEFootballTablesPage } from '../frontend/feFootballTablesPage'; import type { FEFront } from '../frontend/feFront'; +import type { FEShell } from '../frontend/feShell'; import type { FETagPage } from '../frontend/feTagPage'; import articleSchema from '../frontend/schemas/feArticle.json'; import cricketMatchPageSchema from '../frontend/schemas/feCricketMatchPage.json'; @@ -15,6 +16,7 @@ import footballMatchInfoPageSchema from '../frontend/schemas/feFootballMatchInfo import footballMatchListPageSchema from '../frontend/schemas/feFootballMatchListPage.json'; import footballTablesPageSchema from '../frontend/schemas/feFootballTablesPage.json'; import frontSchema from '../frontend/schemas/feFront.json'; +import shellSchema from '../frontend/schemas/feShell.json'; import tagPageSchema from '../frontend/schemas/feTagPage.json'; import type { Block } from '../types/blocks'; import type { FEEditionsCrosswords } from '../types/editionsCrossword'; @@ -57,6 +59,20 @@ const validateFootballMatchInfoPage = ajv.compile( footballMatchInfoPageSchema, ); +const validateShell = ajv.compile(shellSchema); + +export const validateAsFESite = (data: unknown): FEArticle => { + if (validateArticle(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(validateArticle.errors, null, 2)}`, + ); +}; + export const validateAsFEArticle = (data: unknown): FEArticle => { if (validateArticle(data)) return data; @@ -186,3 +202,17 @@ export const validateAsFootballMatchPageType = ( ${JSON.stringify(validateFootballMatchInfoPage.errors, null, 2)}`, ); }; + +export const validateAsFEShell = (data: unknown): FEShell => { + if (validateShell(data)) return data; + + const url = + isObject(data) && isObject(data.config) && isString(data.config.pageId) + ? data.config.pageId + : 'unknown url'; + + throw new TypeError( + `Unable to validate request body for url ${url}.\n + ${JSON.stringify(validateShell.errors, null, 2)}`, + ); +}; diff --git a/dotcom-rendering/src/server/handler.article.web.ts b/dotcom-rendering/src/server/handler.article.web.ts index 73814f36f93..6530d25aee5 100644 --- a/dotcom-rendering/src/server/handler.article.web.ts +++ b/dotcom-rendering/src/server/handler.article.web.ts @@ -11,6 +11,16 @@ import { renderHostedContent, } from './render.article.web'; +export const handleSite: RequestHandler = ({ body }, res) => { + const frontendData = validateAsFEArticle(body); + const article = enhanceArticleType(frontendData, 'Web'); + const { html, prefetchScripts } = renderArticle({ + article, + }); + + res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html); +}; + export const handleArticle: RequestHandler = ({ body }, res) => { const frontendData = validateAsFEArticle(body); const article = enhanceArticleType(frontendData, 'Web'); diff --git a/dotcom-rendering/src/server/handler.shell.web.ts b/dotcom-rendering/src/server/handler.shell.web.ts new file mode 100644 index 00000000000..3beb9cc51b9 --- /dev/null +++ b/dotcom-rendering/src/server/handler.shell.web.ts @@ -0,0 +1,11 @@ +import type { RequestHandler } from 'express'; +import { validateAsFEShell } from '../model/validate'; +import { makePrefetchHeader } from './lib/header'; +import { renderShell } from './render.shell.web'; + +export const handleShell: RequestHandler = ({ body }, res) => { + const frontendData = validateAsFEShell(body); + const { html, prefetchScripts } = renderShell(frontendData); + + res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html); +}; diff --git a/dotcom-rendering/src/server/lib/get-content-from-url.ts b/dotcom-rendering/src/server/lib/get-content-from-url.ts index ebde7b5eaec..5397782bfa7 100644 --- a/dotcom-rendering/src/server/lib/get-content-from-url.ts +++ b/dotcom-rendering/src/server/lib/get-content-from-url.ts @@ -43,6 +43,19 @@ async function getContentFromURL( throw error; }); + // TODO: HACK! + if (url.pathname.startsWith('/shell')) { + return { + ...config, + config: { + ...config.config, + keywordIds: 'shell', + shortUrlId: '', + showRelatedContent: false, + }, + }; + } + return config; } diff --git a/dotcom-rendering/src/server/render.shell.web.tsx b/dotcom-rendering/src/server/render.shell.web.tsx new file mode 100644 index 00000000000..ed2a4b0b1d2 --- /dev/null +++ b/dotcom-rendering/src/server/render.shell.web.tsx @@ -0,0 +1,121 @@ +import { isString } from '@guardian/libs'; +import { ConfigProvider } from '../components/ConfigContext'; +import { ShellPage } from '../components/ShellPage'; +import type { FEShell } from '../frontend/feShell'; +import { + ASSET_ORIGIN, + generateScriptTags, + getModulesBuild, + getPathFromManifest, +} from '../lib/assets'; +import { renderToStringWithEmotion } from '../lib/emotion'; +import { polyfillIO } from '../lib/polyfill.io'; +import type { NavType } from '../model/extract-nav'; +import { extractNAV } from '../model/extract-nav'; +import { createGuardian as createWindowGuardian } from '../model/guardian'; +import type { Config } from '../types/configContext'; +import type { FENavType } from '../types/frontend'; +import { htmlPageTemplate } from './htmlPageTemplate'; + +// TODO: PoC! + +const parseNav = (nav: FENavType): NavType => { + return { + ...extractNAV(nav), + selectedPillar: undefined, + }; +}; + +export const renderShell = ( + frontendData: FEShell, +): { html: string; prefetchScripts: string[] } => { + const NAV = parseNav(frontendData.nav); + + const title = 'Shell Page'; + + const renderingTarget = 'Web'; + const config: Config = { + renderingTarget, + darkModeAvailable: false, + assetOrigin: ASSET_ORIGIN, + editionId: frontendData.editionId, + }; + + const { html, extractedCss } = renderToStringWithEmotion( + + + , + ); + + const build = getModulesBuild({ + tests: frontendData.config.abTests, + switches: frontendData.config.switches, + }); + + /** + * 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 prefetchScripts = [ + polyfillIO, + getPathFromManifest(build, 'frameworks.js'), + getPathFromManifest(build, 'index.js'), + process.env.COMMERCIAL_BUNDLE_URL ?? + frontendData.config.commercialBundleUrl, + ].filter(isString); + + const scriptTags = generateScriptTags(prefetchScripts); + + /** + * We escape windowGuardian here to prevent errors when the data + * is placed in a script tag on the page + */ + const guardian = createWindowGuardian({ + editionId: frontendData.editionId, + stage: frontendData.config.stage, + frontendAssetsFullURL: frontendData.config.frontendAssetsFullURL, + revisionNumber: frontendData.config.revisionNumber, + sentryPublicApiKey: frontendData.config.sentryPublicApiKey, + sentryHost: frontendData.config.sentryHost, + keywordIds: frontendData.config.keywordIds, + dfpAccountId: frontendData.config.dfpAccountId, + adUnit: frontendData.config.adUnit, + ajaxUrl: frontendData.config.ajaxUrl, + googletagUrl: frontendData.config.googletagUrl, + switches: frontendData.config.switches, + abTests: frontendData.config.abTests, + serverSideABTests: frontendData.config.serverSideABTests, + brazeApiKey: frontendData.config.brazeApiKey, + googleRecaptchaSiteKey: frontendData.config.googleRecaptchaSiteKey, + // Until we understand exactly what config we need to make available client-side, + // add everything we haven't explicitly typed as unknown config + unknownConfig: frontendData.config, + }); + + const section = frontendData.config.section; + + const pageHtml = htmlPageTemplate({ + scriptTags, + css: extractedCss, + html, + title, + description: 'Shell page', + guardian, + section, + canonicalUrl: frontendData.canonicalUrl, + renderingTarget: 'Web', + weAreHiring: !!frontendData.config.switches.weAreHiring, + config, + hasLiveBlogTopAd: !!frontendData.config.hasLiveBlogTopAd, + hasSurveyAd: !!frontendData.config.hasSurveyAd, + }); + + return { html: pageHtml, prefetchScripts }; +}; diff --git a/dotcom-rendering/src/server/server.dev.ts b/dotcom-rendering/src/server/server.dev.ts index a8915867297..782cd67c080 100644 --- a/dotcom-rendering/src/server/server.dev.ts +++ b/dotcom-rendering/src/server/server.dev.ts @@ -18,6 +18,7 @@ import { handleAppsAssets } from './handler.assets.apps'; import { handleEditionsCrossword } from './handler.editionsCrossword'; import { handleFootballMatchDayEmbed } from './handler.footballMatchDayEmbed'; import { handleFront, handleTagPage } from './handler.front.web'; +import { handleShell } from './handler.shell.web'; import { handleAppsFootballMatchPage, handleCricketMatchPage, @@ -124,6 +125,7 @@ renderer.get('/HostedContent/*url', handleHostedContent); renderer.get('/AppsHostedContent/*url', handleAppsHostedContent); renderer.get('/AppsComponent/thrasher/:name', handleAppsThrasher); renderer.get('/FootballMatchDayEmbed/*url', handleFootballMatchDayEmbed); +renderer.get('/Shell/*url', handleShell); // POST routes for running frontend locally renderer.post('/Article', handleArticle); @@ -145,6 +147,7 @@ renderer.post('/HostedContent', handleHostedContent); renderer.post('/AppsHostedContent', handleAppsHostedContent); renderer.post('/AppsComponent/thrasher/:name', handleAppsThrasher); renderer.post('/FootballMatchDayEmbed', handleFootballMatchDayEmbed); +renderer.post('/Shell/:name', handleShell); renderer.get('/assets/rendered-items-assets', handleAppsAssets); From 9f02dfc25c5289d01e088077c7e1e83a4b84f496 Mon Sep 17 00:00:00 2001 From: Ravi <7014230+arelra@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:32:15 +0100 Subject: [PATCH 2/6] fashion site --- dotcom-rendering/src/components/ShellPage.tsx | 5 +- dotcom-rendering/src/components/fashion.html | 1574 +++++++++++++++++ .../src/server/lib/get-content-from-url.ts | 1 + .../webpack/webpack.config.server.js | 4 + 4 files changed, 1583 insertions(+), 1 deletion(-) create mode 100644 dotcom-rendering/src/components/fashion.html diff --git a/dotcom-rendering/src/components/ShellPage.tsx b/dotcom-rendering/src/components/ShellPage.tsx index 30f4b6d5209..149caf70621 100644 --- a/dotcom-rendering/src/components/ShellPage.tsx +++ b/dotcom-rendering/src/components/ShellPage.tsx @@ -12,6 +12,8 @@ import type { NavType } from '../model/extract-nav'; import { AlreadyVisited } from './AlreadyVisited.island'; import { useConfig } from './ConfigContext'; import { DarkModeMessage } from './DarkModeMessage'; +// @ts-expect-error -- HTML asset +import html from './fashion.html'; import { FocusStyles } from './FocusStyles.island'; import { Footer } from './Footer'; import { HeaderAdSlot } from './HeaderAdSlot'; @@ -147,7 +149,8 @@ export const ShellPage = (props: Props) => { )} -
Hello DCAR Sites shell!
+ {/*
Hello DCAR sites shell ๐Ÿ‘‹
*/} +
{isWeb && ( <> diff --git a/dotcom-rendering/src/components/fashion.html b/dotcom-rendering/src/components/fashion.html new file mode 100644 index 00000000000..c9287367f19 --- /dev/null +++ b/dotcom-rendering/src/components/fashion.html @@ -0,0 +1,1574 @@ + + + + + + THE EDIT โ€” Guardian Fashion + + + + + + + +
+ + + + + + + + diff --git a/dotcom-rendering/src/server/lib/get-content-from-url.ts b/dotcom-rendering/src/server/lib/get-content-from-url.ts index 5397782bfa7..759e7a1ee29 100644 --- a/dotcom-rendering/src/server/lib/get-content-from-url.ts +++ b/dotcom-rendering/src/server/lib/get-content-from-url.ts @@ -48,6 +48,7 @@ async function getContentFromURL( return { ...config, config: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- poc ...config.config, keywordIds: 'shell', shortUrlId: '', diff --git a/dotcom-rendering/webpack/webpack.config.server.js b/dotcom-rendering/webpack/webpack.config.server.js index bb3282477b8..c56dc6bef0c 100644 --- a/dotcom-rendering/webpack/webpack.config.server.js +++ b/dotcom-rendering/webpack/webpack.config.server.js @@ -82,6 +82,10 @@ module.exports = { exclude: transpileExclude, use: swcLoader, }, + { + test: /\.html$/i, + type: 'asset/source', + }, svgr, { test: /jsdom.*computed-style\.js$/, From bd77a16e3206748661c16bafe8d5a596e7cd5d78 Mon Sep 17 00:00:00 2001 From: Ravi <7014230+arelra@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:06:19 +0100 Subject: [PATCH 3/6] esi edge time --- dotcom-rendering/src/components/ShellPage.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/components/ShellPage.tsx b/dotcom-rendering/src/components/ShellPage.tsx index 149caf70621..bcda5344b6c 100644 --- a/dotcom-rendering/src/components/ShellPage.tsx +++ b/dotcom-rendering/src/components/ShellPage.tsx @@ -12,8 +12,7 @@ import type { NavType } from '../model/extract-nav'; import { AlreadyVisited } from './AlreadyVisited.island'; import { useConfig } from './ConfigContext'; import { DarkModeMessage } from './DarkModeMessage'; -// @ts-expect-error -- HTML asset -import html from './fashion.html'; +// import html from './fashion.html'; import { FocusStyles } from './FocusStyles.island'; import { Footer } from './Footer'; import { HeaderAdSlot } from './HeaderAdSlot'; @@ -149,8 +148,9 @@ export const ShellPage = (props: Props) => { )} - {/*
Hello DCAR sites shell ๐Ÿ‘‹
*/} -
+ {/* @ts-expect-error Fastly ESI namespaced tag is not part of React JSX types */} + + {/*
*/} {isWeb && ( <> From 0527168752dc57d0fb3999db39e8e187f4cd05e0 Mon Sep 17 00:00:00 2001 From: Ravi <7014230+arelra@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:27:22 +0100 Subject: [PATCH 4/6] Add site name --- dotcom-rendering/src/server/handler.shell.web.ts | 10 ++++++++-- dotcom-rendering/src/server/render.shell.web.tsx | 1 + dotcom-rendering/src/server/server.dev.ts | 4 ++-- dotcom-rendering/webpack/.swcrc.json | 3 ++- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/dotcom-rendering/src/server/handler.shell.web.ts b/dotcom-rendering/src/server/handler.shell.web.ts index 3beb9cc51b9..4991e63fa3c 100644 --- a/dotcom-rendering/src/server/handler.shell.web.ts +++ b/dotcom-rendering/src/server/handler.shell.web.ts @@ -3,9 +3,15 @@ import { validateAsFEShell } from '../model/validate'; import { makePrefetchHeader } from './lib/header'; import { renderShell } from './render.shell.web'; -export const handleShell: RequestHandler = ({ body }, res) => { +export const handleShell: RequestHandler = ({ params, body }, res) => { + const { name } = params; + if (typeof name !== 'string') { + res.status(400).send('Invalid site name'); + return; + } + const frontendData = validateAsFEShell(body); - const { html, prefetchScripts } = renderShell(frontendData); + const { html, prefetchScripts } = renderShell(frontendData, name); res.status(200).set('Link', makePrefetchHeader(prefetchScripts)).send(html); }; diff --git a/dotcom-rendering/src/server/render.shell.web.tsx b/dotcom-rendering/src/server/render.shell.web.tsx index ed2a4b0b1d2..407c74782dd 100644 --- a/dotcom-rendering/src/server/render.shell.web.tsx +++ b/dotcom-rendering/src/server/render.shell.web.tsx @@ -28,6 +28,7 @@ const parseNav = (nav: FENavType): NavType => { export const renderShell = ( frontendData: FEShell, + name: string, ): { html: string; prefetchScripts: string[] } => { const NAV = parseNav(frontendData.nav); diff --git a/dotcom-rendering/src/server/server.dev.ts b/dotcom-rendering/src/server/server.dev.ts index 782cd67c080..2aa0815a476 100644 --- a/dotcom-rendering/src/server/server.dev.ts +++ b/dotcom-rendering/src/server/server.dev.ts @@ -125,7 +125,7 @@ renderer.get('/HostedContent/*url', handleHostedContent); renderer.get('/AppsHostedContent/*url', handleAppsHostedContent); renderer.get('/AppsComponent/thrasher/:name', handleAppsThrasher); renderer.get('/FootballMatchDayEmbed/*url', handleFootballMatchDayEmbed); -renderer.get('/Shell/*url', handleShell); +renderer.get('/Site/:name/*url', handleShell); // POST routes for running frontend locally renderer.post('/Article', handleArticle); @@ -147,7 +147,7 @@ renderer.post('/HostedContent', handleHostedContent); renderer.post('/AppsHostedContent', handleAppsHostedContent); renderer.post('/AppsComponent/thrasher/:name', handleAppsThrasher); renderer.post('/FootballMatchDayEmbed', handleFootballMatchDayEmbed); -renderer.post('/Shell/:name', handleShell); +renderer.post('/Site/:name', handleShell); renderer.get('/assets/rendered-items-assets', handleAppsAssets); diff --git a/dotcom-rendering/webpack/.swcrc.json b/dotcom-rendering/webpack/.swcrc.json index 490d70a5893..a0f037cdfc7 100644 --- a/dotcom-rendering/webpack/.swcrc.json +++ b/dotcom-rendering/webpack/.swcrc.json @@ -10,7 +10,8 @@ "transform": { "react": { "runtime": "automatic", - "importSource": "@emotion/react" + "importSource": "@emotion/react", + "throwIfNamespace": false } }, "experimental": { From c915f2b8fbf110501143f3ca69cb4acf570ea645 Mon Sep 17 00:00:00 2001 From: Ravi <7014230+arelra@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:14:42 +0100 Subject: [PATCH 5/6] Add name param to Sites URL path --- dotcom-rendering/src/components/ShellPage.tsx | 1 + dotcom-rendering/src/frontend/schemas/feShell.json | 3 ++- dotcom-rendering/src/server/lib/get-content-from-url.ts | 7 +++++-- dotcom-rendering/src/server/render.shell.web.tsx | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/dotcom-rendering/src/components/ShellPage.tsx b/dotcom-rendering/src/components/ShellPage.tsx index bcda5344b6c..277ba3428de 100644 --- a/dotcom-rendering/src/components/ShellPage.tsx +++ b/dotcom-rendering/src/components/ShellPage.tsx @@ -151,6 +151,7 @@ export const ShellPage = (props: Props) => { {/* @ts-expect-error Fastly ESI namespaced tag is not part of React JSX types */} {/*
*/} +
Hello DCAR Sites shell!
{isWeb && ( <> diff --git a/dotcom-rendering/src/frontend/schemas/feShell.json b/dotcom-rendering/src/frontend/schemas/feShell.json index fb1955ed46d..fb2b39fd9a3 100644 --- a/dotcom-rendering/src/frontend/schemas/feShell.json +++ b/dotcom-rendering/src/frontend/schemas/feShell.json @@ -46,7 +46,8 @@ "type": "object", "properties": { "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`", + "description": "Server-side AB tests. Optional from `frontend`; a default of\n`{}` is applied by AJV during request validation (see `useDefaults` in\n`validate.ts`), so this is always present after enhancing.", + "default": {}, "type": "object" }, "adUnit": { diff --git a/dotcom-rendering/src/server/lib/get-content-from-url.ts b/dotcom-rendering/src/server/lib/get-content-from-url.ts index 759e7a1ee29..48035885ada 100644 --- a/dotcom-rendering/src/server/lib/get-content-from-url.ts +++ b/dotcom-rendering/src/server/lib/get-content-from-url.ts @@ -44,7 +44,7 @@ async function getContentFromURL( }); // TODO: HACK! - if (url.pathname.startsWith('/shell')) { + if (url.pathname.startsWith('/site')) { return { ...config, config: { @@ -65,8 +65,11 @@ async function getContentFromURL( */ export const parseURL = (requestUrl: string): URL | undefined => { try { + const splitPos = requestUrl.toLocaleLowerCase().startsWith('/site/') + ? 3 + : 2; return new URL( - decodeURIComponent(requestUrl.split('/').slice(2).join('/')), + decodeURIComponent(requestUrl.split('/').slice(splitPos).join('/')), ); } catch (error) { return undefined; diff --git a/dotcom-rendering/src/server/render.shell.web.tsx b/dotcom-rendering/src/server/render.shell.web.tsx index 407c74782dd..43ce8efa377 100644 --- a/dotcom-rendering/src/server/render.shell.web.tsx +++ b/dotcom-rendering/src/server/render.shell.web.tsx @@ -32,7 +32,7 @@ export const renderShell = ( ): { html: string; prefetchScripts: string[] } => { const NAV = parseNav(frontendData.nav); - const title = 'Shell Page'; + const title = `Shell Page - ${name}`; const renderingTarget = 'Web'; const config: Config = { From b973159f7b83e1009d693f5ee488e5410abdfeb8 Mon Sep 17 00:00:00 2001 From: Ravi <7014230+arelra@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:50:44 +0100 Subject: [PATCH 6/6] Fix rebase --- dotcom-rendering/src/components/ShellPage.tsx | 1 - dotcom-rendering/src/frontend/schemas/feShell.json | 6 ------ dotcom-rendering/src/server/render.shell.web.tsx | 6 +----- 3 files changed, 1 insertion(+), 12 deletions(-) diff --git a/dotcom-rendering/src/components/ShellPage.tsx b/dotcom-rendering/src/components/ShellPage.tsx index 277ba3428de..a1cb12ceb3a 100644 --- a/dotcom-rendering/src/components/ShellPage.tsx +++ b/dotcom-rendering/src/components/ShellPage.tsx @@ -81,7 +81,6 @@ export const ShellPage = (props: Props) => { commercialMetricsEnabled={ !!config.switches.commercialMetrics } - tests={config.abTests} /> diff --git a/dotcom-rendering/src/frontend/schemas/feShell.json b/dotcom-rendering/src/frontend/schemas/feShell.json index fb2b39fd9a3..aa3b0275916 100644 --- a/dotcom-rendering/src/frontend/schemas/feShell.json +++ b/dotcom-rendering/src/frontend/schemas/feShell.json @@ -45,11 +45,6 @@ "description": "the config model will contain useful app/site\nlevel data. Although currently derived from the config model\nconstructed in frontend and passed to dotcom-rendering\nthis data could eventually be defined in dotcom-rendering", "type": "object", "properties": { - "abTests": { - "description": "Server-side AB tests. Optional from `frontend`; a default of\n`{}` is applied by AJV during request validation (see `useDefaults` in\n`validate.ts`), so this is always present after enhancing.", - "default": {}, - "type": "object" - }, "adUnit": { "type": "string" }, @@ -589,7 +584,6 @@ } }, "required": [ - "abTests", "adUnit", "ajaxUrl", "ampIframeUrl", diff --git a/dotcom-rendering/src/server/render.shell.web.tsx b/dotcom-rendering/src/server/render.shell.web.tsx index 43ce8efa377..66bc3f12bf0 100644 --- a/dotcom-rendering/src/server/render.shell.web.tsx +++ b/dotcom-rendering/src/server/render.shell.web.tsx @@ -52,10 +52,7 @@ export const renderShell = ( , ); - const build = getModulesBuild({ - tests: frontendData.config.abTests, - switches: frontendData.config.switches, - }); + const build = getModulesBuild(); /** * The highest priority scripts. @@ -91,7 +88,6 @@ export const renderShell = ( ajaxUrl: frontendData.config.ajaxUrl, googletagUrl: frontendData.config.googletagUrl, switches: frontendData.config.switches, - abTests: frontendData.config.abTests, serverSideABTests: frontendData.config.serverSideABTests, brazeApiKey: frontendData.config.brazeApiKey, googleRecaptchaSiteKey: frontendData.config.googleRecaptchaSiteKey,