diff --git a/dotcom-rendering/package.json b/dotcom-rendering/package.json index ebb5eb32140..5137f8568b7 100644 --- a/dotcom-rendering/package.json +++ b/dotcom-rendering/package.json @@ -63,6 +63,7 @@ "@babel/preset-typescript": "^7.12.7", "@babel/runtime": "^7.12.5", "@braze/web-sdk-core": "3.5.1", + "@creditkarma/thrift-server-core": "^1.0.4", "@cypress/skip-test": "^2.6.0", "@emotion/babel-plugin": "^11.3.0", "@emotion/cache": "^11.4.0", @@ -71,6 +72,7 @@ "@guardian/ab-core": "^2.0.0", "@guardian/atoms-rendering": "^24.0.0", "@guardian/braze-components": "^8.1.3", + "@guardian/bridget": "^2.0.0", "@guardian/browserslist-config": "^2.0.3", "@guardian/commercial-core": "^5.0.0", "@guardian/consent-management-platform": "11.0.0", @@ -151,6 +153,7 @@ "babel-plugin-polyfill-corejs3": "^0.6.0", "babel-plugin-px-to-rem": "https://github.com/guardian/babel-plugin-px-to-rem#v0.1.0", "babel-plugin-transform-runtime": "^6.23.0", + "buffer": "^6.0.3", "browserslist": "^4.21.4", "bundlesize": "^0.18.1", "chalk": "^4.1.0", diff --git a/dotcom-rendering/scripts/webpack/webpack.config.browser.js b/dotcom-rendering/scripts/webpack/webpack.config.browser.js index d8184d8cb9e..9def61efa56 100644 --- a/dotcom-rendering/scripts/webpack/webpack.config.browser.js +++ b/dotcom-rendering/scripts/webpack/webpack.config.browser.js @@ -1,3 +1,4 @@ +const webpack = require('webpack'); const { WebpackManifestPlugin } = require('webpack-manifest-plugin'); const swcConfig = require('./.swcrc.json'); const { getBrowserTargets } = require('./browser-targets'); @@ -15,7 +16,7 @@ const generateName = (bundle) => { }; /** - * @param {'legacy' | 'modern' | 'variant'} bundle + * @param {'legacy' | 'modern' | 'variant' | 'apps'} bundle * @returns {string} */ const getLoaders = (bundle) => { @@ -48,6 +49,32 @@ const getLoaders = (bundle) => { }, }, ]; + case 'apps': + return [ + { + loader: 'babel-loader', + options: { + presets: [ + '@babel/preset-react', + [ + '@babel/preset-env', + { + bugfixes: true, + targets: ['android >= 5', 'ios >= 12'], + }, + ], + ], + compact: true, + }, + }, + { + loader: 'ts-loader', + options: { + configFile: 'tsconfig.build.json', + transpileOnly: true, + }, + }, + ]; case 'variant': return [ { @@ -93,25 +120,28 @@ const getLoaders = (bundle) => { }; /** - * @param {{ bundle: 'legacy' | 'modern' | 'variant', sessionId: string }} options + * @param {{ bundle: 'legacy' | 'modern' | 'apps' | 'variant', sessionId: string }} options * @returns {import('webpack').Configuration} */ module.exports = ({ bundle, sessionId }) => ({ - entry: { - sentryLoader: './src/web/browser/sentryLoader/init.ts', - bootCmp: './src/web/browser/bootCmp/init.ts', - ga: './src/web/browser/ga/init.ts', - ophan: './src/web/browser/ophan/init.ts', - islands: './src/web/browser/islands/init.ts', - dynamicImport: './src/web/browser/dynamicImport/init.ts', - atomIframe: './src/web/browser/atomIframe/init.ts', - embedIframe: './src/web/browser/embedIframe/init.ts', - newsletterEmbedIframe: - './src/web/browser/newsletterEmbedIframe/init.ts', - relativeTime: './src/web/browser/relativeTime/init.ts', - initDiscussion: './src/web/browser/initDiscussion/init.ts', - debug: './src/web/browser/debug/init.ts', - }, + entry: + bundle === 'apps' + ? './src/app/client/init.ts' + : { + sentryLoader: './src/web/browser/sentryLoader/init.ts', + bootCmp: './src/web/browser/bootCmp/init.ts', + ga: './src/web/browser/ga/init.ts', + ophan: './src/web/browser/ophan/init.ts', + islands: './src/web/browser/islands/init.ts', + dynamicImport: './src/web/browser/dynamicImport/init.ts', + atomIframe: './src/web/browser/atomIframe/init.ts', + embedIframe: './src/web/browser/embedIframe/init.ts', + newsletterEmbedIframe: + './src/web/browser/newsletterEmbedIframe/init.ts', + relativeTime: './src/web/browser/relativeTime/init.ts', + initDiscussion: './src/web/browser/initDiscussion/init.ts', + debug: './src/web/browser/debug/init.ts', + }, output: { filename: (data) => { // We don't want to hash the debug script so it can be used in bookmarklets @@ -121,10 +151,28 @@ module.exports = ({ bundle, sessionId }) => ({ chunkFilename: generateName(bundle), publicPath: '', }, + resolve: + bundle === 'apps' + ? { + fallback: { + buffer: require.resolve('buffer/'), + }, + } + : undefined, plugins: [ new WebpackManifestPlugin({ fileName: `manifest.${bundle}.json`, }), + ...(bundle === 'apps' + ? [ + new webpack.optimize.LimitChunkCountPlugin({ + maxChunks: 1, + }), + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + }), + ] + : []), ...(DEV ? [ new GuStatsReportPlugin({ diff --git a/dotcom-rendering/scripts/webpack/webpack.config.js b/dotcom-rendering/scripts/webpack/webpack.config.js index 782369b91a7..d901e288b81 100644 --- a/dotcom-rendering/scripts/webpack/webpack.config.js +++ b/dotcom-rendering/scripts/webpack/webpack.config.js @@ -19,7 +19,7 @@ const sessionId = uuidv4(); let builds = 0; /** - * @param {{ platform: 'server' | 'browser.legacy' | 'browser.modern' | 'browser.variant'}} options + * @param {{ platform: 'server' | 'browser.legacy' | 'browser.modern' | 'browser.apps' | 'browser.variant'}} options * @returns {import('webpack').Configuration} */ const commonConfigs = ({ platform }) => ({ @@ -131,6 +131,15 @@ module.exports = [ sessionId, }), ), + merge( + commonConfigs({ + platform: 'browser.apps', + }), + require(`./webpack.config.browser`)({ + bundle: 'apps', + sessionId, + }), + ), ...(BUILD_VARIANT ? [ merge( diff --git a/dotcom-rendering/src/app/client/init.ts b/dotcom-rendering/src/app/client/init.ts new file mode 100644 index 00000000000..54ecf18b598 --- /dev/null +++ b/dotcom-rendering/src/app/client/init.ts @@ -0,0 +1,7 @@ +import '../../web/browser/dynamicImport/init.ts'; +import '../../web/browser/islands/init.ts'; +import '../../web/browser/atomIframe/init.ts'; +import '../../web/browser/embedIframe/init.ts'; +import '../../web/browser/newsletterEmbedIframe/init.ts'; +import '../../web/browser/relativeTime/init.ts'; +// import '../../web/browser/initDiscussion/init.ts'; diff --git a/dotcom-rendering/src/app/components/ArticlePage.tsx b/dotcom-rendering/src/app/components/ArticlePage.tsx new file mode 100644 index 00000000000..bf74b06d176 --- /dev/null +++ b/dotcom-rendering/src/app/components/ArticlePage.tsx @@ -0,0 +1,70 @@ +import { css, Global } from '@emotion/react'; +import { ArticleDesign } from '@guardian/libs'; +import { brandAlt, focusHalo, neutral } from '@guardian/source-foundations'; +import { StrictMode } from 'react'; +// import { filterABTestSwitches } from '../../model/enhance-switches'; +import type { FEArticleType } from '../../types/frontend'; +import { FetchCommentCounts } from '../../web/components/FetchCommentCounts.importable'; +import { FocusStyles } from '../../web/components/FocusStyles.importable'; +import { Island } from '../../web/components/Island'; +// import { SetABTests } from './SetABTests.importable'; +import { SkipTo } from '../../web/components/SkipTo'; +import { DecideLayout } from '../layouts/DecideLayout'; + +type Props = { + CAPIArticle: FEArticleType; + format: ArticleFormat; +}; + +/** + * @description + * Article is a high level wrapper for article pages on Dotcom. Sets strict mode and some globals + * + * @param {Props} props + * @param {FEArticleType} props.CAPIArticle - The article JSON data + * @param {NAVType} props.NAV - The article JSON data + * @param {ArticleFormat} props.format - The format model for the article + * */ +export const ArticlePage = ({ CAPIArticle, format }: Props) => { + return ( + + + + {(format.design === ArticleDesign.LiveBlog || + format.design === ArticleDesign.DeadBlog) && ( + + )} + + + + {/* + + */} + + + + {/* + + */} + + + ); +}; diff --git a/dotcom-rendering/src/app/layouts/DecideLayout.tsx b/dotcom-rendering/src/app/layouts/DecideLayout.tsx new file mode 100644 index 00000000000..28aae16abd3 --- /dev/null +++ b/dotcom-rendering/src/app/layouts/DecideLayout.tsx @@ -0,0 +1,64 @@ +import { ArticleDesign, ArticleDisplay } from '@guardian/libs'; +import type { ArticleFormat } from '@guardian/libs'; +import type { FEArticleType } from '../../types/frontend'; +import { StandardLayout } from './StandardLayout'; + +type Props = { + CAPIArticle: FEArticleType; + format: ArticleFormat; +}; + +export const DecideLayout = ({ CAPIArticle, format }: Props) => { + switch (format.display) { + case ArticleDisplay.Immersive: { + switch (format.design) { + case ArticleDesign.Interactive: { + return
Not Supported
; + } + default: { + return
Not Supported
; + } + } + } + case ArticleDisplay.NumberedList: + case ArticleDisplay.Showcase: { + switch (format.design) { + case ArticleDesign.LiveBlog: + case ArticleDesign.DeadBlog: + return
Not Supported
; + case ArticleDesign.Comment: + case ArticleDesign.Editorial: + case ArticleDesign.Letter: + return
Not Supported
; + default: + return
Not Supported
; + } + } + case ArticleDisplay.Standard: + default: { + switch (format.design) { + case ArticleDesign.Interactive: + return
Not Supported
; + case ArticleDesign.FullPageInteractive: { + return
Not Supported
; + } + case ArticleDesign.LiveBlog: + case ArticleDesign.DeadBlog: + return
Not Supported
; + case ArticleDesign.Comment: + case ArticleDesign.Editorial: + case ArticleDesign.Letter: + return
Not Supported
; + case ArticleDesign.NewsletterSignup: + return
Not Supported
; + default: + return ( + + ); + } + } + } +}; diff --git a/dotcom-rendering/src/app/layouts/StandardLayout.tsx b/dotcom-rendering/src/app/layouts/StandardLayout.tsx new file mode 100644 index 00000000000..929ca652de4 --- /dev/null +++ b/dotcom-rendering/src/app/layouts/StandardLayout.tsx @@ -0,0 +1,795 @@ +import { css } from '@emotion/react'; +import { ArticleDesign, ArticleSpecial } from '@guardian/libs'; +import type { ArticleFormat } from '@guardian/libs'; +import { + border, + brandAltBackground, + from, + labs, + until, +} from '@guardian/source-foundations'; +import { StraightLines } from '@guardian/source-react-components-development-kitchen'; +import type { FEArticleType } from '../../types/frontend'; +import { Platform } from '../../types/platform'; +import { AppsArticleBody } from '../../web/components/ArticleBody'; +import { ArticleContainer } from '../../web/components/ArticleContainer'; +import { ArticleHeadline } from '../../web/components/ArticleHeadline'; +import { ArticleMeta } from '../../web/components/ArticleMeta'; +import { ArticleTitle } from '../../web/components/ArticleTitle'; +import { Border } from '../../web/components/Border'; +import { Carousel } from '../../web/components/Carousel.importable'; +import { DecideLines } from '../../web/components/DecideLines'; +import { DecideOnwards } from '../../web/components/DecideOnwards'; +import { GetMatchNav } from '../../web/components/GetMatchNav.importable'; +import { GetMatchStats } from '../../web/components/GetMatchStats.importable'; +import { GetMatchTabs } from '../../web/components/GetMatchTabs.importable'; +import { GridItem } from '../../web/components/GridItem'; +import { GuardianLabsLines } from '../../web/components/GuardianLabsLines'; +import { Island } from '../../web/components/Island'; +import { LabsHeader } from '../../web/components/LabsHeader.importable'; +import { MainMedia } from '../../web/components/MainMedia'; +import { MostViewedFooterData } from '../../web/components/MostViewedFooterData.importable'; +import { MostViewedFooterLayout } from '../../web/components/MostViewedFooterLayout'; +import { MostViewedRightWrapper } from '../../web/components/MostViewedRightWrapper.importable'; +import { OnwardsUpper } from '../../web/components/OnwardsUpper.importable'; +import { RightColumn } from '../../web/components/RightColumn'; +import { Section } from '../../web/components/Section'; +import { Standfirst } from '../../web/components/Standfirst'; +import { StarRating } from '../../web/components/StarRating/StarRating'; +import { SubMeta } from '../../web/components/SubMeta'; +import { TableOfContents } from '../../web/components/TableOfContents'; +import { Stuck } from '../../web/layouts/lib/stickiness'; +import { decidePalette } from '../../web/lib/decidePalette'; +import { decideTrail } from '../../web/lib/decideTrail'; + +const StandardGrid = ({ + children, + isMatchReport, +}: { + children: React.ReactNode; + isMatchReport: boolean; +}) => ( +
+ {children} +
+); + +const maxWidth = css` + ${from.desktop} { + max-width: 620px; + } +`; + +const stretchLines = css` + ${until.phablet} { + margin-left: -20px; + margin-right: -20px; + } + ${until.mobileLandscape} { + margin-left: -10px; + margin-right: -10px; + } +`; + +const starWrapper = css` + margin-bottom: 18px; + margin-top: 6px; + background-color: ${brandAltBackground.primary}; + display: inline-block; + + ${until.phablet} { + padding-left: 20px; + margin-left: -20px; + } + ${until.leftCol} { + padding-left: 0px; + margin-left: -0px; + } + + padding-left: 10px; + margin-left: -10px; +`; + +interface Props { + CAPIArticle: FEArticleType; + format: ArticleFormat; +} + +export const StandardLayout = ({ CAPIArticle, format }: Props) => { + const { + config: { isPaidContent, host }, + } = CAPIArticle; + + const footballMatchUrl = + CAPIArticle.matchType === 'FootballMatchType' && CAPIArticle.matchUrl; + + const isMatchReport = + format.design === ArticleDesign.MatchReport && !!footballMatchUrl; + + // const showComments = CAPIArticle.isCommentable; + + // const { branding } = + // CAPIArticle.commercialProperties[CAPIArticle.editionId]; + + const palette = decidePalette(format); + + // const contributionsServiceUrl = getContributionsServiceUrl(CAPIArticle); + + /** + * This property currently only applies to the header and merchandising slots + */ + // const renderAds = !CAPIArticle.isAdFreeUser && !CAPIArticle.shouldHideAds; + + return ( + <> + {/* TODO: Review this because AR didn't previously support labs articles */} + {format.theme === ArticleSpecial.Labs && ( + +
+ + + +
+
+ )} + +
+
+ + + + + + {format.theme === ArticleSpecial.Labs ? ( + <> + ) : ( + + )} + + +
+ {isMatchReport && ( + + + + )} +
+
+ +
+ {isMatchReport && ( + + + + )} +
+
+ +
+ +
+ {CAPIArticle.starRating || + CAPIArticle.starRating === 0 ? ( +
+ +
+ ) : ( + <> + )} +
+ + + + +
+ +
+
+ +
+
+ {format.theme === ArticleSpecial.Labs ? ( + + ) : ( + + )} +
+
+
+ +
+ +
+
+ + + {CAPIArticle.tableOfContents && ( +
+ +
+ )} + + {format.design === ArticleDesign.MatchReport && + !!footballMatchUrl && ( + + + + )} + + {/* We know we need something like this but we don't know what yet */} + {/* {showBodyEndSlot && ( + + + + )} */} + + +
+
+ +
+ + {/* Todo: make this an 'app ad' */} + {/* {!CAPIArticle.shouldHideAds && ( + + )} */} + {!isPaidContent ? ( + + + + ) : ( + <> + )} + +
+
+
+
+ + {/* Todo: do we want this? */} + {/* {renderAds && !isLabs && ( +
+ +
+ )} */} + + {CAPIArticle.onwards ? ( + + ) : ( + <> + {CAPIArticle.storyPackage && ( +
+ + + +
+ )} + + + + + + )} + + {/* TODO: Review if we can get discussion working */} + {/* {!isPaidContent && showComments && ( +
+ +
+ )} */} + + {!isPaidContent && ( +
+ + + + + +
+ )} + + {/* Todo: review if we need this */} + {/* {renderAds && !isLabs && ( +
+ +
+ )} */} +
+ {/*(*/} + + ); +}; diff --git a/dotcom-rendering/src/app/native/nativeApi.ts b/dotcom-rendering/src/app/native/nativeApi.ts new file mode 100644 index 00000000000..80f9e15e94b --- /dev/null +++ b/dotcom-rendering/src/app/native/nativeApi.ts @@ -0,0 +1,72 @@ +import * as Acquisitions from '@guardian/bridget/Acquisitions'; +import * as Analytics from '@guardian/bridget/Analytics'; +import * as Commercial from '@guardian/bridget/Commercial'; +import * as Discussion from '@guardian/bridget/Discussion'; +import * as Environment from '@guardian/bridget/Environment'; +import * as Gallery from '@guardian/bridget/Gallery'; +import * as Metrics from '@guardian/bridget/Metrics'; +import * as Navigation from '@guardian/bridget/Navigation'; +import * as Newsletters from '@guardian/bridget/Newsletters'; +import * as Notifications from '@guardian/bridget/Notifications'; +import * as User from '@guardian/bridget/User'; +import * as Video from '@guardian/bridget/Videos'; +import { createAppClient } from './thrift/nativeConnection'; + +const environmentClient: Environment.Client = createAppClient< + Environment.Client +>(Environment.Client, 'buffered', 'compact'); +const commercialClient: Commercial.Client = createAppClient< + Commercial.Client +>(Commercial.Client, 'buffered', 'compact'); +const acquisitionsClient: Acquisitions.Client = createAppClient< + Acquisitions.Client +>(Acquisitions.Client, 'buffered', 'compact'); +const notificationsClient: Notifications.Client = createAppClient< + Notifications.Client +>(Notifications.Client, 'buffered', 'compact'); +const userClient: User.Client = createAppClient>( + User.Client, + 'buffered', + 'compact', +); +const galleryClient: Gallery.Client = createAppClient< + Gallery.Client +>(Gallery.Client, 'buffered', 'compact'); +const videoClient: Video.Client = createAppClient>( + Video.Client, + 'buffered', + 'compact', +); +const metricsClient: Metrics.Client = createAppClient< + Metrics.Client +>(Metrics.Client, 'buffered', 'compact'); +const discussionClient: Discussion.Client = createAppClient< + Discussion.Client +>(Discussion.Client, 'buffered', 'compact'); + +const analyticsClient: Analytics.Client = createAppClient< + Analytics.Client +>(Analytics.Client, 'buffered', 'compact'); + +const navigationClient: Navigation.Client = createAppClient< + Navigation.Client +>(Navigation.Client, 'buffered', 'compact'); + +const newslettersClient: Newsletters.Client = createAppClient< + Newsletters.Client +>(Newsletters.Client, 'buffered', 'compact'); + +export { + environmentClient, + commercialClient, + acquisitionsClient, + notificationsClient, + userClient, + galleryClient, + videoClient, + metricsClient, + discussionClient, + analyticsClient, + navigationClient, + newslettersClient, +}; diff --git a/dotcom-rendering/src/app/native/thrift/nativeConnection.ts b/dotcom-rendering/src/app/native/thrift/nativeConnection.ts new file mode 100644 index 00000000000..a3b9b12f709 --- /dev/null +++ b/dotcom-rendering/src/app/native/thrift/nativeConnection.ts @@ -0,0 +1,159 @@ +import type { + IClientConstructor, + IProtocolConstructor, + ITransportConstructor, + ProtocolType, + ThriftClient, + TransportType, + TTransport, +} from '@creditkarma/thrift-server-core'; +import { + getProtocol, + getTransport, + TApplicationException, + TApplicationExceptionType, + ThriftConnection, +} from '@creditkarma/thrift-server-core'; +import * as uuid from 'uuid'; +import { TMultiplexedProtocol } from './protocols'; + +declare global { + interface Window { + nativeConnections?: Record; + android?: { + postMessage: (data: string, connectionId: string) => void; + }; + webkit?: { + messageHandlers: { + iOSWebViewMessage: { + postMessage: (nativeMessage: NativeMessage) => void; + }; + }; + }; + } +} + +export interface NativeMessage { + data: string; + connectionId: string; +} + +interface PromiseResponse { + resolve: (response: Buffer) => void; + reject: (error: Error) => void; + timeoutId: NodeJS.Timeout; +} + +const ACTION_TIMEOUT_MS = 30000; + +function sendNativeMessage(nativeMessage: NativeMessage): void { + if (window.android) { + window.android.postMessage( + nativeMessage.data, + nativeMessage.connectionId, + ); + } else if (window.webkit) { + window.webkit.messageHandlers.iOSWebViewMessage.postMessage( + nativeMessage, + ); + } else { + console.warn('No native APIs available'); + } +} + +export class NativeConnection extends ThriftConnection { + connectionId = uuid.v4(); + promises: PromiseResponse[] = []; + outBuffer: NativeMessage[] = []; + + constructor( + Transport: ITransportConstructor, + Protocol: IProtocolConstructor, + ) { + super(Transport, Protocol); + if (typeof window !== 'undefined') { + window.nativeConnections = window.nativeConnections ?? {}; + window.nativeConnections[this.connectionId] = this; + } + } + + reset(oldConnectionId: string): void { + if (oldConnectionId === this.connectionId && window.nativeConnections) { + console.warn('Reseting connection ' + oldConnectionId); + delete window.nativeConnections[this.connectionId]; + this.promises.forEach((promise) => { + promise.reject( + new TApplicationException( + TApplicationExceptionType.UNKNOWN, + 'Timeout error', + ), + ); + }); + this.promises = []; + this.connectionId = uuid.v4(); + window.nativeConnections[this.connectionId] = this; + } + } + + receive(message: NativeMessage): void { + const resolver = this.promises.shift(); + if (resolver) { + clearTimeout(resolver.timeoutId); + const data = Buffer.from(message.data, 'base64'); + resolver.resolve(data); + } + this.sendNextMessage(); + } + + private sendNextMessage(): void { + const message = this.outBuffer.shift(); + if (message) { + console.log('Sending next message'); + sendNativeMessage(message); + } + } + + send(dataToSend: Buffer, context?: void | undefined): Promise { + const id = this.connectionId; + // eslint-disable-next-line @typescript-eslint/no-this-alias -- Reassign this + const connection = this; + return new Promise(function (res, rej): void { + connection.promises.push({ + resolve: res, + reject: rej, + timeoutId: setTimeout(function () { + connection.reset(id); + }, ACTION_TIMEOUT_MS), + }); + const message: NativeMessage = { + data: dataToSend.toString('base64'), + connectionId: id, + }; + if (connection.promises.length === 1) { + console.log('Sending message immediately'); + sendNativeMessage(message); + } else { + console.log('Queing message because others in flight'); + connection.outBuffer.push(message); + } + }); + } +} + +export function createAppClient>( + ServiceClient: IClientConstructor, + transport: TransportType = 'buffered', + protocol: ProtocolType = 'compact', +): TClient { + class NamedMultiplexedProtocol extends TMultiplexedProtocol { + constructor(transport: TTransport) { + const Protocol = getProtocol(protocol); + super(new Protocol(transport), ServiceClient.serviceName ?? ''); + } + } + const connection = new NativeConnection( + getTransport(transport), + NamedMultiplexedProtocol, + ); + return new ServiceClient(connection); +} diff --git a/dotcom-rendering/src/app/native/thrift/protocols.ts b/dotcom-rendering/src/app/native/thrift/protocols.ts new file mode 100644 index 00000000000..8878b7f4458 --- /dev/null +++ b/dotcom-rendering/src/app/native/thrift/protocols.ts @@ -0,0 +1,176 @@ +import type { + Int64, + IThriftField, + IThriftList, + IThriftMap, + IThriftMessage, + IThriftSet, + IThriftStruct, + TTransport, + TType, +} from '@creditkarma/thrift-server-core'; +import { MessageType, TProtocol } from '@creditkarma/thrift-server-core'; + +export abstract class TProtocolDecorator extends TProtocol { + private concreteProtocol: TProtocol; + + constructor(protocol: TProtocol) { + super(protocol.getTransport()); + this.concreteProtocol = protocol; + } + + getTransport(): TTransport { + return this.concreteProtocol.getTransport(); + } + flush(): Buffer { + return this.concreteProtocol.flush(); + } + writeMessageBegin(name: string, type: MessageType, seqid: number): void { + return this.concreteProtocol.writeMessageBegin(name, type, seqid); + } + writeMessageEnd(): void { + return this.concreteProtocol.writeMessageEnd(); + } + writeStructBegin(name: string): void { + return this.concreteProtocol.writeStructBegin(name); + } + writeStructEnd(): void { + return this.concreteProtocol.writeStructEnd(); + } + writeFieldBegin(name: string, type: TType, id: number): void { + return this.concreteProtocol.writeFieldBegin(name, type, id); + } + writeFieldEnd(): void { + return this.concreteProtocol.writeFieldEnd(); + } + writeFieldStop(): void { + return this.concreteProtocol.writeFieldStop(); + } + writeMapBegin(keyType: TType, valueType: TType, size: number): void { + return this.concreteProtocol.writeMapBegin(keyType, valueType, size); + } + writeMapEnd(): void { + return this.concreteProtocol.writeMapEnd(); + } + writeListBegin(elementType: TType, size: number): void { + return this.concreteProtocol.writeListBegin(elementType, size); + } + writeListEnd(): void { + return this.concreteProtocol.writeListEnd(); + } + writeSetBegin(elementType: TType, size: number): void { + return this.concreteProtocol.writeSetBegin(elementType, size); + } + writeSetEnd(): void { + return this.concreteProtocol.writeSetEnd(); + } + writeBool(bool: boolean): void { + return this.concreteProtocol.writeBool(bool); + } + writeByte(b: number): void { + return this.concreteProtocol.writeByte(b); + } + writeI16(i16: number): void { + return this.concreteProtocol.writeI16(i16); + } + writeI32(i32: number): void { + return this.concreteProtocol.writeI32(i32); + } + writeI64(i64: number | Int64): void { + return this.concreteProtocol.writeI64(i64); + } + writeDouble(dbl: number): void { + return this.concreteProtocol.writeDouble(dbl); + } + writeString(arg: string): void { + return this.concreteProtocol.writeString(arg); + } + writeBinary(arg: string | Buffer): void { + return this.concreteProtocol.writeBinary(arg); + } + readMessageBegin(): IThriftMessage { + return this.concreteProtocol.readMessageBegin(); + } + readMessageEnd(): void { + return this.concreteProtocol.readMessageEnd(); + } + readStructBegin(): IThriftStruct { + return this.concreteProtocol.readStructBegin(); + } + readStructEnd(): void { + return this.concreteProtocol.readStructEnd(); + } + readFieldBegin(): IThriftField { + return this.concreteProtocol.readFieldBegin(); + } + readFieldEnd(): void { + return this.concreteProtocol.readFieldEnd(); + } + readMapBegin(): IThriftMap { + return this.concreteProtocol.readMapBegin(); + } + readMapEnd(): void { + return this.concreteProtocol.readMapEnd(); + } + readListBegin(): IThriftList { + return this.concreteProtocol.readListBegin(); + } + readListEnd(): void { + return this.concreteProtocol.readListEnd(); + } + readSetBegin(): IThriftSet { + return this.concreteProtocol.readSetBegin(); + } + readSetEnd(): void { + return this.concreteProtocol.readSetEnd(); + } + readBool(): boolean { + return this.concreteProtocol.readBool(); + } + readByte(): number { + return this.concreteProtocol.readByte(); + } + readI16(): number { + return this.concreteProtocol.readI16(); + } + readI32(): number { + return this.concreteProtocol.readI32(); + } + readI64(): Int64 { + return this.concreteProtocol.readI64(); + } + readDouble(): number { + return this.concreteProtocol.readDouble(); + } + readBinary(): Buffer { + return this.concreteProtocol.readBinary(); + } + readString(): string { + return this.concreteProtocol.readString(); + } + skip(type: TType): void { + return this.concreteProtocol.skip(type); + } +} + +export class TMultiplexedProtocol extends TProtocolDecorator { + static readonly separator = ':'; + readonly serviceName: string; + + constructor(protocol: TProtocol, serviceName: string) { + super(protocol); + this.serviceName = serviceName; + } + + writeMessageBegin(name: string, type: MessageType, seqid: number): void { + if (type === MessageType.CALL || type === MessageType.ONEWAY) { + super.writeMessageBegin( + this.serviceName + TMultiplexedProtocol.separator + name, + type, + seqid, + ); + } else { + super.writeMessageBegin(name, type, seqid); + } + } +} diff --git a/dotcom-rendering/src/app/server/articleToHtml.tsx b/dotcom-rendering/src/app/server/articleToHtml.tsx new file mode 100644 index 00000000000..05d33dfb200 --- /dev/null +++ b/dotcom-rendering/src/app/server/articleToHtml.tsx @@ -0,0 +1,195 @@ +import createCache from '@emotion/cache'; +import { CacheProvider } from '@emotion/react'; +import createEmotionServer from '@emotion/server/create-instance'; +import { ArticleDesign, ArticlePillar } from '@guardian/libs'; +import { renderToString } from 'react-dom/server'; +import { generateScriptTags, getAppScript } from '../../lib/assets'; +import { escapeData } from '../../lib/escapeData'; +// import { makeWindowGuardian } from '../../model/window-guardian'; +import type { CAPIElement } from '../../types/content'; +import type { FEArticleType } from '../../types/frontend'; +import { decideFormat } from '../../web/lib/decideFormat'; +import { decideTheme } from '../../web/lib/decideTheme'; +import { getHttp3Url } from '../../web/lib/getHttp3Url'; +import { ArticlePage } from '../components/ArticlePage'; +import { pageTemplate } from './pageTemplate'; + +interface Props { + article: FEArticleType; +} + +const decideTitle = (article: FEArticleType): string => { + if ( + decideTheme(article.format) === ArticlePillar.Opinion && + article.byline + ) { + return `${article.headline} | ${article.byline}`; + } + return `${article.headline} | ${article.sectionLabel}`; +}; + +export const articleToHtml = ({ + article, +}: Props): { html: string; clientScript: string } => { + const title = decideTitle(article); + const key = 'ar'; + const cache = createCache({ key }); + + // eslint-disable-next-line @typescript-eslint/unbound-method + const { extractCriticalToChunks, constructStyleTagsFromChunks } = + createEmotionServer(cache); + + const format: ArticleFormat = decideFormat(article.format); + + const html = renderToString( + + + , + ); + + const chunks = extractCriticalToChunks(html); + const extractedCss = constructStyleTagsFromChunks(chunks); + + // We want to only insert script tags for the elements or main media elements on this page view + // so we need to check what elements we have and use the mapping to the the chunk name + const CAPIElements: CAPIElement[] = article.blocks + .map((block) => block.elements) + .flat(); + + // Evaluating the performance of HTTP3 over HTTP2 + // See: https://github.com/guardian/dotcom-rendering/pull/5394 + const { offerHttp3 = false } = article.config.switches; + + // const pageHasNonBootInteractiveElements = CAPIElements.some( + // (element) => + // element._type === + // 'model.dotcomrendering.pageElements.InteractiveBlockElement' && + // element.scriptUrl !== + // 'https://interactive.guim.co.uk/embed/iframe-wrapper/0.1/boot.js', // We have rewritten this standard behaviour into Dotcom Rendering + // ); + + const pageHasTweetElements = CAPIElements.some( + (element) => + element._type === + 'model.dotcomrendering.pageElements.TweetBlockElement', + ); + + // const shouldServeVariantBundle: boolean = [ + // BUILD_VARIANT, + // article.config.abTests[dcrJavascriptBundle('Variant')] === 'variant', + // ].every(Boolean); + + /** + * This function returns an array of files found in the manifests + * defined by `manifestPaths`. + * + * @see getScriptsFromManifest + */ + // const getScriptArrayFromFile = getScriptsFromManifest( + // shouldServeVariantBundle, + // ); + + /** + * 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 clientScript = getAppScript(); + const priorityScriptTags = generateScriptTags( + [clientScript].map((script) => + offerHttp3 && script ? getHttp3Url(script) : script, + ), + ); + + const lowPriorityScriptTags: string[] = []; + + /** + * We escape windowGuardian here to prevent errors when the data + * is placed in a script tag on the page + */ + const windowGuardian = escapeData( + JSON.stringify({ + config: { + frontendAssetsFullURL: article.config.frontendAssetsFullURL, + page: { + ajaxUrl: article.config.ajaxUrl, + }, + }, + }), + ); + // const windowGuardian = escapeData( + // JSON.stringify( + // makeWindowGuardian({ + // editionId: article.editionId, + // stage: article.config.stage, + // frontendAssetsFullURL: article.config.frontendAssetsFullURL, + // revisionNumber: article.config.revisionNumber, + // sentryPublicApiKey: article.config.sentryPublicApiKey, + // sentryHost: article.config.sentryHost, + // keywordIds: article.config.keywordIds, + // dfpAccountId: article.config.dfpAccountId, + // adUnit: article.config.adUnit, + // ajaxUrl: article.config.ajaxUrl, + // googletagUrl: article.config.googletagUrl, + // switches: article.config.switches, + // abTests: article.config.abTests, + // brazeApiKey: article.config.brazeApiKey, + // isPaidContent: article.pageType.isPaidContent, + // contentType: article.contentType, + // shouldHideReaderRevenue: article.shouldHideReaderRevenue, + // GAData: extractGA({ + // webTitle: article.webTitle, + // format: article.format, + // sectionName: article.sectionName, + // contentType: article.contentType, + // tags: article.tags, + // pageId: article.pageId, + // editionId: article.editionId, + // beaconURL: article.beaconURL, + // }), + // unknownConfig: article.config, + // }), + // ), + // ); + + const initTwitter = ` +`; + + return { + html: pageTemplate({ + priorityScriptTags, + lowPriorityScriptTags, + css: extractedCss, + html, + title, + description: article.trailText, + windowGuardian, + initTwitter: + pageHasTweetElements || format.design === ArticleDesign.LiveBlog + ? initTwitter + : undefined, + offerHttp3, + }), + clientScript, + }; +}; diff --git a/dotcom-rendering/src/app/server/index.ts b/dotcom-rendering/src/app/server/index.ts new file mode 100644 index 00000000000..87982b3c339 --- /dev/null +++ b/dotcom-rendering/src/app/server/index.ts @@ -0,0 +1,139 @@ +import type { RequestHandler } from 'express'; +import { Standard as ExampleArticle } from '../../../fixtures/generated/articles/Standard'; +import { isRecipe } from '../../model/enhance-recipes'; +import { enhanceBlocks } from '../../model/enhanceBlocks'; +import { enhanceCommercialProperties } from '../../model/enhanceCommercialProperties'; +import { enhanceStandfirst } from '../../model/enhanceStandfirst'; +import { enhanceTableOfContents } from '../../model/enhanceTableOfContents'; +import { validateAsCAPIType } from '../../model/validate'; +import type { FEArticleType } from '../../types/frontend'; +import { articleToHtml } from './articleToHtml'; + +function enhancePinnedPost(format: CAPIFormat, block?: Block) { + return block ? enhanceBlocks([block], format)[0] : block; +} + +const enhanceCAPIType = (body: unknown): FEArticleType => { + const data = validateAsCAPIType(body); + + const enhancedBlocks = enhanceBlocks(data.blocks, data.format, { + promotedNewsletter: data.promotedNewsletter, + isRecipe: isRecipe(data.tags), + }); + + const CAPIArticle: FEArticleType = { + ...data, + blocks: enhancedBlocks, + pinnedPost: enhancePinnedPost(data.format, data.pinnedPost), + standfirst: enhanceStandfirst(data.standfirst), + commercialProperties: enhanceCommercialProperties( + data.commercialProperties, + ), + tableOfContents: data.config.switches.tableOfContents + ? enhanceTableOfContents(data.format, enhancedBlocks) + : undefined, + }; + return CAPIArticle; +}; + +const getStack = (e: unknown): string => + e instanceof Error ? e.stack ?? 'No error stack' : 'Unknown error'; + +const getPrefetchHeader = (script: string): string => { + return `<${script}>; rel=prefetch`; +}; + +export const handleArticle: RequestHandler = ({ body }, res) => { + try { + const article = enhanceCAPIType(body); + const { html, clientScript } = articleToHtml({ + article, + }); + + res.set('Link', getPrefetchHeader(clientScript)); + res.status(200).send(html); + } catch (e) { + res.status(500).send(`
${getStack(e)}
`); + } +}; + +export const handleArticleJson: RequestHandler = ({ body }, res) => { + try { + const CAPIArticle = enhanceCAPIType(body); + const resp = { + data: { + CAPIArticle, + }, + }; + + res.status(200).send(resp); + } catch (e) { + res.status(500).send(`
${getStack(e)}
`); + } +}; + +export const handlePerfTest: RequestHandler = (req, res, next) => { + req.body = ExampleArticle; + handleArticle(req, res, next); +}; + +export const handleInteractive: RequestHandler = ({ body }, res) => { + try { + const article = enhanceCAPIType(body); + const resp = articleToHtml({ + article, + }); + + res.status(200).send(resp.html); + } catch (e) { + res.status(500).send(`
${getStack(e)}
`); + } +}; + +// We might need this for liveblogs +// export const handleBlocks: RequestHandler = ({ body }, res) => { +// try { +// const { +// blocks, +// format, +// host, +// pageId, +// webTitle, +// ajaxUrl, +// isAdFreeUser, +// isSensitive, +// videoDuration, +// edition, +// section, +// sharedAdTargeting, +// adUnit, +// switches, +// keywordIds, +// } = +// // The content if body is not checked +// body as BlocksRequest; + +// const enhancedBlocks = enhanceBlocks(blocks, format); +// const html = blocksToHtml({ +// blocks: enhancedBlocks, +// format, +// host, +// pageId, +// webTitle, +// ajaxUrl, +// isAdFreeUser, +// isSensitive, +// videoDuration, +// edition, +// section, +// sharedAdTargeting, +// adUnit, +// switches, +// keywordIds, +// }); + +// res.status(200).send(html); +// } catch (e) { +// res.status(500).send(`
${getStack(e)}
`); +// } +// }; diff --git a/dotcom-rendering/src/app/server/pageTemplate.ts b/dotcom-rendering/src/app/server/pageTemplate.ts new file mode 100644 index 00000000000..9acf125cec7 --- /dev/null +++ b/dotcom-rendering/src/app/server/pageTemplate.ts @@ -0,0 +1,207 @@ +import { brandBackground, resets } from '@guardian/source-foundations'; +import he from 'he'; +import { ASSET_ORIGIN } from '../../lib/assets'; +import { getFontsCss } from '../../lib/fonts-css'; +import { getHttp3Url } from '../../web/lib/getHttp3Url'; + +export const pageTemplate = ({ + css, + html, + windowGuardian, + priorityScriptTags, + lowPriorityScriptTags, + offerHttp3, + title = 'The Guardian', + description = 'Latest news, sport, business, comment, analysis and reviews from the Guardian, the world's leading liberal voice', + initTwitter, +}: { + css: string; + html: string; + windowGuardian: string; + priorityScriptTags: string[]; + lowPriorityScriptTags: string[]; + offerHttp3: boolean; + title?: string; + description?: string; + initTwitter?: string; +}): string => { + const favicon = + process.env.NODE_ENV === 'production' + ? 'favicon-32x32.ico' + : 'favicon-32x32-dev-yellow.ico'; + + /** + * Preload the following woff2 font files + * TODO: Identify critical fonts to preload + */ + const fontFiles = [ + // 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-Light.woff2', + // 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-LightItalic.woff2', + 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-Medium.woff2', + 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-MediumItalic.woff2', + 'https://assets.guim.co.uk/static/frontend/fonts/guardian-headline/noalts-not-hinted/GHGuardianHeadline-Bold.woff2', + 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textegyptian/noalts-not-hinted/GuardianTextEgyptian-Regular.woff2', + // 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textegyptian/noalts-not-hinted/GuardianTextEgyptian-RegularItalic.woff2', + 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textegyptian/noalts-not-hinted/GuardianTextEgyptian-Bold.woff2', + 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textsans/noalts-not-hinted/GuardianTextSans-Regular.woff2', + // 'http://assets.guim.co.uk/static/frontend/fonts/guardian-textsans/noalts-not-hinted/GuardianTextSans-RegularItalic.woff2', + 'https://assets.guim.co.uk/static/frontend/fonts/guardian-textsans/noalts-not-hinted/GuardianTextSans-Bold.woff2', + ].map((font) => (offerHttp3 ? getHttp3Url(font) : font)); + + const fontPreloadTags = fontFiles.map( + (fontFile) => + ``, + ); + + // Opt out of having information from our website used for personalization of content and suggestions for Twitter users, including ads + // See https://developer.twitter.com/en/docs/twitter-for-websites/webpage-properties/overview + const twitterSecAndPrivacyMetaTags = ``; + + // Duplicated prefetch and preconnect tags from DCP: + // Documented here: https://github.com/guardian/frontend/pull/12935 + // Preconnect should be used for the most crucial third party domains + // "use preconnect when you know for sure that you’re going to be accessing a resource" + // - https://www.smashingmagazine.com/2019/04/optimization-performance-resource-hints/ + // DNS-prefetch should be used for other third party domains that we are likely to connect to but not sure (ads) + // Preconnecting to too many URLs can reduce page performance + // DNS-prefetch can also be used as a fallback for IE11 + // More information on preconnecting: + // https://css-tricks.com/using-relpreconnect-to-establish-network-connections-early-and-increase-performance/ + // More information on prefetching: + // https://developer.mozilla.org/en-US/docs/Web/Performance/dns-prefetch + const staticPreconnectUrls = [ + `${ASSET_ORIGIN}`, + `https://i.guim.co.uk`, + `https://j.ophan.co.uk`, + `https://ophan.theguardian.com`, + ]; + + const staticPrefetchUrls = [ + ...staticPreconnectUrls, + `https://api.nextgen.guardianapps.co.uk`, + `https://hits-secure.theguardian.com`, + `https://interactive.guim.co.uk`, + `https://phar.gu-web.net`, + `https://static.theguardian.com`, + `https://support.theguardian.com`, + ]; + + const allStaticPreconnectUrls = + process.env.NODE_ENV === 'production' + ? [...staticPreconnectUrls, 'https://sourcepoint.theguardian.com'] + : staticPreconnectUrls; + + const preconnectTags = allStaticPreconnectUrls.map( + (src) => ``, + ); + + const prefetchTags = staticPrefetchUrls.map( + (src) => ``, + ); + + const weAreHiringMessage = ` +`; + + return ` + + + ${weAreHiringMessage} + ${title} + + + + + + + + ${preconnectTags.join('\n')} + ${prefetchTags.join('\n')} + + + ${fontPreloadTags.join('\n')} + + ${twitterSecAndPrivacyMetaTags} + + + + + + + + + + + ${initTwitter ?? ''} + + ${priorityScriptTags.join('\n')} + + + + ${css} + + + + + ${html} + ${[...lowPriorityScriptTags].join('\n')} + + `; +}; diff --git a/dotcom-rendering/src/lib/assets.ts b/dotcom-rendering/src/lib/assets.ts index 3a55a922666..c53c62ec698 100644 --- a/dotcom-rendering/src/lib/assets.ts +++ b/dotcom-rendering/src/lib/assets.ts @@ -15,6 +15,7 @@ interface AssetHash { export const decideAssetOrigin = ( stage: string | undefined, isDev: boolean, + isApp?: boolean, ): string => { switch (stage?.toUpperCase()) { case 'PROD': @@ -22,7 +23,10 @@ export const decideAssetOrigin = ( case 'CODE': return 'https://assets-code.guim.co.uk/'; default: { - if (isDev) { + // We shouldn't assume localhost for apps testing + // The app emulator does not connect on localhost, so + // assuming localhost causes asset resolution to fail + if (isDev && !isApp) { // Use absolute asset paths in development mode // This is so paths are correct when treated as relative to Frontend return 'http://localhost:3030/'; @@ -94,6 +98,20 @@ const getScripts = ( }); }; +export const getAppScript = (): string => { + const appAssetOrigin = decideAssetOrigin(process.env.GU_STAGE, isDev, true); + if (isDev) { + return `${appAssetOrigin}assets/main.apps.js`; + } + + const manifest = getManifest('./manifest.apps.json'); + const mainJs = manifest['main.js']; + if (!mainJs) { + throw new Error(`main.js not in manifest`); + } + return `${appAssetOrigin}assets/${mainJs}`; +}; + /** * A curried function that takes an array of manifests. * diff --git a/dotcom-rendering/src/server/dev-server.ts b/dotcom-rendering/src/server/dev-server.ts index 7bd55544a53..53375f21a7a 100644 --- a/dotcom-rendering/src/server/dev-server.ts +++ b/dotcom-rendering/src/server/dev-server.ts @@ -1,5 +1,6 @@ import type { Handler } from 'express'; import { handleAMPArticle } from '../amp/server'; +import { handleArticle as handleAppArticle } from '../app/server'; import { handleArticle, handleArticleJson, @@ -38,6 +39,8 @@ export const devServer = (): Handler => { return handleFront(req, res, next); case '/FrontJSON': return handleFrontJson(req, res, next); + case '/mobile-apps/Article': + return handleAppArticle(req, res, next); default: { if (req.url.match(ARTICLE_URL)) { const url = new URL( diff --git a/dotcom-rendering/src/server/prod-server.ts b/dotcom-rendering/src/server/prod-server.ts index a87183935fc..fd229259250 100644 --- a/dotcom-rendering/src/server/prod-server.ts +++ b/dotcom-rendering/src/server/prod-server.ts @@ -17,6 +17,7 @@ import { handleInteractive, handleKeyEvents, } from '../web/server'; +import { handleArticle as handleAppArticle } from '../app/server'; import { recordBaselineCloudWatchMetrics } from './lib/aws/metrics-baseline'; import { getContentFromURLMiddleware } from './lib/get-content-from-url'; import { logger } from './lib/logging'; @@ -53,6 +54,7 @@ export const prodServer = (): void => { } app.post('/Article', logRenderTime, handleArticle); + app.post('/mobile-apps/Article', logRenderTime, handleAppArticle); app.post('/AMPArticle', logRenderTime, handleAMPArticle); app.post('/Interactive', logRenderTime, handleInteractive); app.post('/AMPInteractive', logRenderTime, handleAMPArticle); @@ -70,6 +72,13 @@ export const prodServer = (): void => { ); app.use('/ArticleJson', handleArticleJson); + app.get( + '/mobile-apps/Article', + logRenderTime, + getContentFromURLMiddleware, + handleAppArticle, + ); + app.get( '/AMPArticle', logRenderTime, diff --git a/dotcom-rendering/src/types/palette.ts b/dotcom-rendering/src/types/palette.ts index e98c063d15b..6ff4bcc4f2b 100644 --- a/dotcom-rendering/src/types/palette.ts +++ b/dotcom-rendering/src/types/palette.ts @@ -8,6 +8,7 @@ export type Palette = { sectionTitle: Colour; seriesTitleWhenMatch: Colour; byline: Colour; + follow: Colour; twitterHandle: Colour; twitterHandleBelowDesktop: Colour; caption: Colour; diff --git a/dotcom-rendering/src/types/platform.ts b/dotcom-rendering/src/types/platform.ts new file mode 100644 index 00000000000..cb2899d4e83 --- /dev/null +++ b/dotcom-rendering/src/types/platform.ts @@ -0,0 +1,6 @@ +export enum Platform { + Web, + AMP, + Apps, + Editions, +} diff --git a/dotcom-rendering/src/types/props.ts b/dotcom-rendering/src/types/props.ts new file mode 100644 index 00000000000..95f1c6e51d8 --- /dev/null +++ b/dotcom-rendering/src/types/props.ts @@ -0,0 +1,12 @@ +import type { Platform } from './platform'; + +type AppsProps = Props extends void + ? { platform: Platform.Apps } + : Props & { platform: Platform.Apps }; +type WebProps = Props extends void + ? { platform: Platform.Web } + : Props & { platform: Platform.Web }; + +type AppsOrWeb = AppsProps | WebProps; + +export type CombinedProps = Common & AppsOrWeb; diff --git a/dotcom-rendering/src/web/browser/islands/getIsandsByName.ts b/dotcom-rendering/src/web/browser/islands/getIsandsByName.ts new file mode 100644 index 00000000000..a678c6da8f0 --- /dev/null +++ b/dotcom-rendering/src/web/browser/islands/getIsandsByName.ts @@ -0,0 +1,8 @@ +export const getIslandsByName = (name: string): HTMLElement[] => { + const rawElements = document.querySelectorAll(`gu-island[name="${name}"]`); + const elements: HTMLElement[] = []; + rawElements.forEach((element) => + element instanceof HTMLElement ? elements.push(element) : null, + ); + return elements; +}; diff --git a/dotcom-rendering/src/web/browser/islands/init.ts b/dotcom-rendering/src/web/browser/islands/init.ts index 9baa2895471..1b27223857d 100644 --- a/dotcom-rendering/src/web/browser/islands/init.ts +++ b/dotcom-rendering/src/web/browser/islands/init.ts @@ -4,10 +4,14 @@ import { startup } from '../startup'; import { initHydration } from './initHydration'; const init = () => { + console.log('hello world2'); + const elements = document.querySelectorAll('gu-island'); initHydration(elements); return Promise.resolve(); }; +console.log('hello world'); + startup('islands', null, init); diff --git a/dotcom-rendering/src/web/components/AppsLightbox.importable.tsx b/dotcom-rendering/src/web/components/AppsLightbox.importable.tsx new file mode 100644 index 00000000000..49b0ef98762 --- /dev/null +++ b/dotcom-rendering/src/web/components/AppsLightbox.importable.tsx @@ -0,0 +1,60 @@ +import { css } from '@emotion/react'; +import { Image } from '@guardian/bridget/Image'; +import { galleryClient } from '../../app/native/nativeApi'; +import { getIslandsByName } from '../browser/islands/getIsandsByName'; +import { getProps } from '../browser/islands/getProps'; + +type Props = { + master: string; + width: string; + height: string; + caption?: string; + credit?: string; +}; + +const buttonStyles = css` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; +`; + +const getLightboxElements = (): Props[] => { + const elements = getIslandsByName('AppsLightbox'); + return elements.map((element) => getProps(element) as Props); +}; + +export const AppsLightbox = (props: Props) => { + const launchLightbox = () => { + const elements = getLightboxElements(); + + const bridgetElements = elements.map( + ({ master, width, height, caption, credit }) => + new Image({ + url: master, + caption, + credit, + width: parseInt(width), + height: parseInt(height), + }), + ); + + const selectedIndex = bridgetElements.findIndex( + (element) => element.url === props.master, + ); + + void galleryClient.launchSlideshow( + bridgetElements, + selectedIndex, + document.title, + ); + }; + + return ( + + ); +}; diff --git a/dotcom-rendering/src/web/components/ArticleBody.tsx b/dotcom-rendering/src/web/components/ArticleBody.tsx index 6117b3f4fac..25964b9d667 100644 --- a/dotcom-rendering/src/web/components/ArticleBody.tsx +++ b/dotcom-rendering/src/web/components/ArticleBody.tsx @@ -5,19 +5,24 @@ import { between, body, headline, space } from '@guardian/source-foundations'; import { isRecipe } from '../../model/enhance-recipes'; import type { Switches } from '../../types/config'; import type { Palette } from '../../types/palette'; +import { Platform } from '../../types/platform'; +import type { CombinedProps } from '../../types/props'; import type { TagType } from '../../types/tag'; import { ArticleRenderer } from '../lib/ArticleRenderer'; import { decidePalette } from '../lib/decidePalette'; -import { LiveBlogRenderer } from '../lib/LiveBlogRenderer'; +import { + AppsLiveBlogRenderer, + WebLiveBlogRenderer, +} from '../lib/LiveBlogRenderer'; import { revealStyles } from '../lib/revealStyles'; import { Island } from './Island'; import { RecipeMultiplier } from './RecipeMultiplier.importable'; -type Props = { +type CommonProps = { format: ArticleFormat; blocks: Block[]; pinnedPost?: Block; - adTargeting: AdTargeting; + adTargeting?: AdTargeting; host?: string; pageId: string; webTitle: string; @@ -28,7 +33,6 @@ type Props = { shouldHideReaderRevenue: boolean; tags: TagType[]; isPaidContent: boolean; - contributionsServiceUrl: string; contentType: string; sectionName: string; keywordIds: string; @@ -41,6 +45,12 @@ type Props = { filterKeyEvents?: boolean; availableTopics?: Topic[]; selectedTopics?: Topic[]; +}; + +type AppsProps = void; + +type WebProps = { + contributionsServiceUrl: string; isInLiveblogAdSlotTest?: boolean; }; @@ -108,36 +118,38 @@ const globalLinkStyles = (palette: Palette) => css` } `; -export const ArticleBody = ({ - format, - blocks, - pinnedPost, - adTargeting, - host, - pageId, - webTitle, - ajaxUrl, - switches, - isAdFreeUser, - section, - shouldHideReaderRevenue, - tags, - isPaidContent, - contributionsServiceUrl, - contentType, - sectionName, - isPreview, - idUrl, - isSensitive, - isDev, - onFirstPage, - keyEvents, - filterKeyEvents, - availableTopics, - selectedTopics, - keywordIds, - isInLiveblogAdSlotTest = false, -}: Props) => { +const ArticleBody = ( + props: CombinedProps, +) => { + const { + format, + blocks, + pinnedPost, + adTargeting, + host, + pageId, + webTitle, + ajaxUrl, + switches, + isAdFreeUser, + section, + shouldHideReaderRevenue, + tags, + isPaidContent, + contentType, + sectionName, + isPreview, + idUrl, + isSensitive, + isDev, + onFirstPage, + keyEvents, + filterKeyEvents, + availableTopics, + selectedTopics, + keywordIds, + platform, + } = props; const isInteractive = format.design === ArticleDesign.Interactive; const palette = decidePalette(format); @@ -161,32 +173,59 @@ export const ArticleBody = ({ revealStyles, ]} > - + {platform === Platform.Web ? ( + + ) : ( + + )} ); } @@ -225,7 +264,16 @@ export const ArticleBody = ({ isDev={isDev} isAdFreeUser={isAdFreeUser} isSensitive={isSensitive} + platform={platform} /> ); }; + +export const WebArticleBody = (props: CommonProps & WebProps) => ( + +); + +export const AppsArticleBody = (props: CommonProps) => ( + +); diff --git a/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx b/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx index 860614cf5aa..434a07d5e88 100644 --- a/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx +++ b/dotcom-rendering/src/web/components/ArticleHeadline.stories.tsx @@ -6,6 +6,7 @@ import { ArticleSpecial, } from '@guardian/libs'; import { news } from '@guardian/source-foundations'; +import { Platform } from '../../types/platform'; import { ArticleContainer } from './ArticleContainer'; import { ArticleHeadline } from './ArticleHeadline'; import { mainMediaElements } from './ArticleHeadline.mocks'; @@ -108,6 +109,7 @@ export const ShowcaseInterview = () => { isAdFreeUser={false} isSensitive={false} switches={{}} + platform={Platform.Web} /> @@ -152,6 +154,7 @@ export const ShowcaseInterviewNobyline = () => { isAdFreeUser={false} isSensitive={false} switches={{}} + platform={Platform.Web} /> @@ -196,6 +199,7 @@ export const Interview = () => { isAdFreeUser={false} isSensitive={false} switches={{}} + platform={Platform.Web} /> @@ -238,6 +242,7 @@ export const InterviewSpecialReport = () => { isAdFreeUser={false} isSensitive={false} switches={{}} + platform={Platform.Web} /> @@ -282,6 +287,7 @@ export const InterviewNoByline = () => { isAdFreeUser={false} isSensitive={false} switches={{}} + platform={Platform.Web} /> diff --git a/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx b/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx index dd488b50364..3ce6c16e822 100644 --- a/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx +++ b/dotcom-rendering/src/web/components/ArticleMeta.stories.tsx @@ -5,6 +5,7 @@ import { ArticlePillar, ArticleSpecial, } from '@guardian/libs'; +import { Platform } from '../../types/platform'; import { getAllThemes, getThemeNameAsString } from '../lib/format'; import { ArticleMeta } from './ArticleMeta'; @@ -69,6 +70,7 @@ export const ArticleStory = () => { return ( { return ( { return ( { return ( { return ( { return ( { return ( { return ( { return ( { return ( { return ( {

{getThemeNameAsString(format)}

{ return ( { const { container } = render( { const { container } = render( + {platform === Platform.Apps && ( + + + + )} diff --git a/dotcom-rendering/src/web/components/Follow.importable.tsx b/dotcom-rendering/src/web/components/Follow.importable.tsx new file mode 100644 index 00000000000..e38fc2a278c --- /dev/null +++ b/dotcom-rendering/src/web/components/Follow.importable.tsx @@ -0,0 +1,144 @@ +import { css } from '@emotion/react'; +import { Topic } from '@guardian/bridget/Topic'; +import { ArticleSpecial } from '@guardian/libs'; +import { space, textSans } from '@guardian/source-foundations'; +import { useCallback, useEffect, useState } from 'react'; +import { notificationsClient } from '../../app/native/nativeApi'; +import type { Palette } from '../../types/palette'; +import type { TagType } from '../../types/tag'; +import { decidePalette } from '../lib/decidePalette'; + +type Props = { + tags: TagType[]; + format: ArticleFormat; +}; + +const getContributorTags = (tags: TagType[]): TagType[] => + tags.filter((tag) => tag.type === 'Contributor'); + +const FollowIcon = ({ isFollowing }: { isFollowing: boolean }) => { + const check = + 'M16.171 10.64L15.411 11.34L16.206 12.09L16.947 12.806L20 9.924L19.259 9.208L16.947 11.391L16.171 10.641L16.171 10.64Z'; + const plus = + 'M17.667 13.5h-1v-2.167H14.5v-1h2.167V8.167h1v2.166h2.166v1h-2.166V13.5z'; + + return ( + + + + ); +}; + +const buttonStyles = (palette: Palette) => css` + ${textSans.small()} + color: ${palette.text.follow}; + display: block; + padding: 0; + border: none; + background: none; + margin-left: 0; + margin-top: ${space[1]}px; + min-height: ${space[6]}px; + + svg { + width: ${space[6]}px; + height: ${space[6]}px; + fill: currentColor; + } + + ${ + /*darkModeCss` + color: ${text.followDark(format)}; + `*/ '' + } +`; + +const spanStyles = css` + display: flex; + align-items: center; + column-gap: 0.2em; +`; + +export const Follow = ({ tags, format }: Props) => { + const contributors = getContributorTags(tags); + if (contributors.length !== 1 || format.theme == ArticleSpecial.Labs) + return null; + + const contributor = contributors[0]; + if (!contributor || !contributor.id.startsWith('profile/')) return null; + + return ; +}; + +export const FollowButton = ({ + contributor, + format, +}: { + contributor: TagType; + format: ArticleFormat; +}) => { + const palette = decidePalette(format); + + const [isFollowing, setIsFollowing] = useState(false); + + useEffect(() => { + const topic = new Topic({ + id: contributor.id, + displayName: contributor.title, + type: 'tag-contributor', + }); + + notificationsClient + .isFollowing(topic) + .then((value) => { + setIsFollowing(value); + }) + .catch(() => {}); + }, [contributor]); + + const onFollowingClick = useCallback(() => { + const topic = new Topic({ + id: contributor.id, + displayName: contributor.title, + type: 'tag-contributor', + }); + + if (isFollowing) { + notificationsClient + .unfollow(topic) + .then(() => setIsFollowing(false)) + .catch(() => {}); + } else { + notificationsClient + .follow(topic) + .then(() => setIsFollowing(true)) + .catch(() => {}); + } + }, [contributor, isFollowing]); + + return ( + + ); +}; diff --git a/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx b/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx index 310e710b6d0..9f7a6c61a88 100644 --- a/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx +++ b/dotcom-rendering/src/web/components/ImageBlockComponent.stories.tsx @@ -1,5 +1,6 @@ import { css } from '@emotion/react'; import { ArticleDesign, ArticleDisplay, ArticlePillar } from '@guardian/libs'; +import { Platform } from '../../types/platform'; import { Figure } from './Figure'; import { Flex } from './Flex'; import { ImageBlockComponent } from './ImageBlockComponent'; @@ -61,6 +62,7 @@ export const StandardArticle = () => { role="inline" > { role="immersive" > { role="showcase" > { role="thumbnail" > { role="supporting" > { role="inline" > { role="inline" > { role="inline" > { role="immersive" > { role="showcase" > { role="halfWidth" > { role="halfWidth" > { role="halfWidth" > ( ); export const ImageComponent = ({ + platform, element, format, hideCaption, @@ -319,6 +324,19 @@ export const ImageComponent = ({ isLazy={!isMainMedia} isMainMedia={isMainMedia} /> + + + {!!title && ( )} @@ -350,6 +368,19 @@ export const ImageComponent = ({ isLazy={!isMainMedia} isMainMedia={isMainMedia} /> + + + {typeof starRating === 'number' && ( )} @@ -384,6 +415,19 @@ export const ImageComponent = ({ isLazy={!isMainMedia} isMainMedia={isMainMedia} /> + + + {isMainMedia && ( // Below tablet, main media images show an info toggle at the bottom right of // the image which, when clicked, toggles the caption as an overlay diff --git a/dotcom-rendering/src/web/components/LiveBlock.stories.tsx b/dotcom-rendering/src/web/components/LiveBlock.stories.tsx index 74a724509e7..5280a33fd45 100644 --- a/dotcom-rendering/src/web/components/LiveBlock.stories.tsx +++ b/dotcom-rendering/src/web/components/LiveBlock.stories.tsx @@ -4,6 +4,7 @@ import { breakpoints, from } from '@guardian/source-foundations'; import { liveBlock } from '../../../fixtures/manual/liveBlock'; import { images } from '../../../fixtures/generated/images'; import { LiveBlock } from './LiveBlock'; +import { Platform } from '../../types/platform'; const Wrapper = ({ children }: { children: React.ReactNode }) => { return ( @@ -81,6 +82,7 @@ export const VideoAsSecond = () => { isSensitive={false} switches={{}} isPinnedPost={false} + platform={Platform.Web} /> ); @@ -130,6 +132,7 @@ export const Title = () => { isSensitive={false} switches={{}} isPinnedPost={false} + platform={Platform.Web} />
); @@ -200,6 +203,7 @@ export const Video = () => { isSensitive={false} switches={{}} isPinnedPost={false} + platform={Platform.Web} />
); @@ -245,6 +249,7 @@ export const RichLink = () => { isSensitive={false} switches={{}} isPinnedPost={false} + platform={Platform.Web} />
); @@ -281,6 +286,7 @@ export const FirstImage = () => { isSensitive={false} switches={{}} isPinnedPost={false} + platform={Platform.Web} />
); @@ -341,6 +347,7 @@ export const ImageRoles = () => { ajaxUrl="" switches={{}} isPinnedPost={false} + platform={Platform.Web} isAdFreeUser={false} isSensitive={false} /> @@ -392,6 +399,7 @@ export const Thumbnail = () => { ajaxUrl="" switches={{}} isPinnedPost={false} + platform={Platform.Web} isAdFreeUser={false} isSensitive={false} /> @@ -431,6 +439,7 @@ export const ImageAndTitle = () => { isSensitive={false} switches={{}} isPinnedPost={false} + platform={Platform.Web} />
); @@ -464,6 +473,7 @@ export const Updated = () => { isSensitive={false} switches={{}} isPinnedPost={false} + platform={Platform.Web} />
); @@ -501,6 +511,7 @@ export const Contributor = () => { ajaxUrl="" switches={{}} isPinnedPost={false} + platform={Platform.Web} isAdFreeUser={false} isSensitive={false} /> @@ -536,6 +547,7 @@ export const NoAvatar = () => { ajaxUrl="" switches={{}} isPinnedPost={false} + platform={Platform.Web} isAdFreeUser={false} isSensitive={false} /> @@ -574,6 +586,7 @@ export const TitleAndContributor = () => { ajaxUrl="" switches={{}} isPinnedPost={false} + platform={Platform.Web} isAdFreeUser={false} isSensitive={false} /> diff --git a/dotcom-rendering/src/web/components/LiveBlock.tsx b/dotcom-rendering/src/web/components/LiveBlock.tsx index 71c152c7b7b..117df7bfa76 100644 --- a/dotcom-rendering/src/web/components/LiveBlock.tsx +++ b/dotcom-rendering/src/web/components/LiveBlock.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/react'; +import { Platform } from '../../types/platform'; import type { Switches } from '../../types/config'; import { RenderArticleElement } from '../lib/renderElement'; import { LastUpdated } from './LastUpdated'; @@ -10,7 +11,7 @@ type Props = { block: Block; pageId: string; webTitle: string; - adTargeting: AdTargeting; + adTargeting?: AdTargeting; host?: string; ajaxUrl: string; isAdFreeUser: boolean; @@ -19,6 +20,7 @@ type Props = { isLiveUpdate?: boolean; isPinnedPost: boolean; pinnedPostId?: string; + platform: Platform; }; export const LiveBlock = ({ @@ -35,6 +37,7 @@ export const LiveBlock = ({ isLiveUpdate, isPinnedPost, pinnedPostId, + platform, }: Props) => { if (block.elements.length === 0) return null; @@ -79,6 +82,7 @@ export const LiveBlock = ({ isSensitive={isSensitive} switches={switches} isPinnedPost={isPinnedPost} + platform={platform} /> ))}