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..a1cb12ceb3a --- /dev/null +++ b/dotcom-rendering/src/components/ShellPage.tsx @@ -0,0 +1,226 @@ +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 html from './fashion.html'; +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 && ( + + )} + + {/* @ts-expect-error Fastly ESI namespaced tag is not part of React JSX types */} + + {/*
*/} +
Hello DCAR Sites shell!
+ + {isWeb && ( + <> + {props.nav.subNavSections && ( +
+ + + +
+ )} + +
+
+
+ + + + + + + )} + {isApps && ( + <> +
Coming soon. Thanks for dropping by.
+ + )} + + ); +}; 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/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..aa3b0275916 --- /dev/null +++ b/dotcom-rendering/src/frontend/schemas/feShell.json @@ -0,0 +1,853 @@ +{ + "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": { + "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": [ + "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..4991e63fa3c --- /dev/null +++ b/dotcom-rendering/src/server/handler.shell.web.ts @@ -0,0 +1,17 @@ +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 = ({ 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, name); + + 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..48035885ada 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,20 @@ async function getContentFromURL( throw error; }); + // TODO: HACK! + if (url.pathname.startsWith('/site')) { + return { + ...config, + config: { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- poc + ...config.config, + keywordIds: 'shell', + shortUrlId: '', + showRelatedContent: false, + }, + }; + } + return config; } @@ -51,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 new file mode 100644 index 00000000000..66bc3f12bf0 --- /dev/null +++ b/dotcom-rendering/src/server/render.shell.web.tsx @@ -0,0 +1,118 @@ +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, + name: string, +): { html: string; prefetchScripts: string[] } => { + const NAV = parseNav(frontendData.nav); + + const title = `Shell Page - ${name}`; + + const renderingTarget = 'Web'; + const config: Config = { + renderingTarget, + darkModeAvailable: false, + assetOrigin: ASSET_ORIGIN, + editionId: frontendData.editionId, + }; + + const { html, extractedCss } = renderToStringWithEmotion( + + + , + ); + + const build = getModulesBuild(); + + /** + * 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, + 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..2aa0815a476 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('/Site/:name/*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('/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": { 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$/,