diff --git a/src/components/blocks/image.ts b/src/components/blocks/image.ts index a5e0f6349..377174206 100644 --- a/src/components/blocks/image.ts +++ b/src/components/blocks/image.ts @@ -2,6 +2,7 @@ import React from 'react'; import * as AssetUtils from 'utils/Asset'; +import { BlockElement } from 'types/capi-thrift-models'; // ----- Setup ----- // @@ -17,6 +18,9 @@ interface Image { // ----- Functions ----- // +const isImage = (elem: BlockElement): boolean => + elem.type === 'image'; + const imageElement = (alt: string, assets: AssetUtils.Asset[], salt: string): React.ReactNode => h('img', { sizes: '100%', @@ -39,6 +43,7 @@ function imageBlock(image: Image, assets: AssetUtils.Asset[], salt: string): Rea // ----- Exports ----- // export { + isImage, imageBlock, imageElement }; diff --git a/src/components/liveblog/LiveblogArticle.tsx b/src/components/liveblog/LiveblogArticle.tsx index e1c4d2344..b99e15d5f 100644 --- a/src/components/liveblog/LiveblogArticle.tsx +++ b/src/components/liveblog/LiveblogArticle.tsx @@ -8,12 +8,14 @@ import LiveblogKeyEvents from './LiveblogKeyEvents'; import LiveblogBody from './LiveblogBody'; import HeaderImage from '../shared/HeaderImage'; import Tags from '../shared/Tags'; -import { Option } from 'types/Option'; -import { PillarStyles, PillarId, wideColumnWidth, baseMultiply } from 'styles'; -import { Series, Contributor } from '../../types/Capi'; -import { Tag, Block, BlockElement } from 'types/capi-thrift-models'; +import { PillarStyles, wideColumnWidth, baseMultiply, getPillarStyles } from 'styles'; +import { Tag } from 'types/capi-thrift-models'; import { css, SerializedStyles } from '@emotion/core' import { palette, wide } from '@guardian/src-foundations' +import { Env } from 'types/Env'; +import { Reader } from 'types/Reader'; +import { isImage } from 'components/blocks/image'; +import { fromNullable } from 'types/Option'; const LiveblogArticleStyles: SerializedStyles = css` background: ${palette.neutral[97]}; @@ -43,62 +45,52 @@ const HeaderImageStyles = (pillarStyles: PillarStyles): SerializedStyles => css` `; interface LiveblogArticleProps { - headline: string; - standfirst: string; - bylineHtml: string; - webPublicationDate: string; - body: string; - tags: Tag[]; - pillarId: PillarId; - mainImage: Option; - pillarStyles: PillarStyles; - contributors: Contributor[]; - series: Series; - bodyElements: Block[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + capi: any; isLive: boolean; - imageSalt: string; } -const LiveblogArticle = ({ - headline, - standfirst, - bylineHtml, - webPublicationDate, - pillarId, - tags, - mainImage, - pillarStyles, - contributors, - series, - bodyElements, - imageSalt -}: LiveblogArticleProps): JSX.Element => -
-
- - - - - - - - -
-
+const LiveblogArticle = ({ capi }: LiveblogArticleProps): Reader => Reader.do(function* () { + + const { type, fields, tags, webPublicationDate, pillarId, blocks } = capi; + const [series] = tags.filter((tag: Tag) => tag.type === 'series'); + const pillarStyles = getPillarStyles(pillarId); + const contributors = tags.filter((tag: Tag) => tag.type === 'contributor'); + const bodyElements = type === 'liveblog' ? blocks.body : blocks.body[0].elements; + const image = fromNullable(blocks.main.elements.filter(isImage)[0]); + + const headerImage = yield HeaderImage({ image: image, className: HeaderImageStyles(pillarStyles) }); + const liveblogByline = yield LiveblogByline({ + byline: fields.bylineHtml, + pillarId, + publicationDate: webPublicationDate, + contributors, + }); + const liveblogBody = yield LiveblogBody({ bodyElements, pillarStyles }); + + return Reader.of( +
+
+ + + + { liveblogByline } + { headerImage } + + { liveblogBody } + +
+
+ ); +}()); export default LiveblogArticle; diff --git a/src/components/liveblog/LiveblogBody.tsx b/src/components/liveblog/LiveblogBody.tsx index c35aeaded..676167aa1 100644 --- a/src/components/liveblog/LiveblogBody.tsx +++ b/src/components/liveblog/LiveblogBody.tsx @@ -6,6 +6,8 @@ import { render } from 'renderBlocks'; import { Block } from 'types/capi-thrift-models'; import { css, SerializedStyles } from '@emotion/core' +import { Reader } from 'types/Reader'; +import { Env } from 'types/Env'; const LiveBodyStyles = (pillarStyles: PillarStyles): SerializedStyles => css` .rich-link, @@ -25,33 +27,32 @@ const LiveBodyStyles = (pillarStyles: PillarStyles): SerializedStyles => css` interface LiveblogBodyProps { pillarStyles: PillarStyles; bodyElements: Block[]; - imageSalt: string; } -const LiveblogBody= ({ pillarStyles, bodyElements, imageSalt }: LiveblogBodyProps): JSX.Element => { +function LiveblogBody({ pillarStyles, bodyElements }: LiveblogBodyProps): Reader { const initialBlocks = bodyElements.slice(0, 7); const LoadMore = ({ total }: { total: number }): JSX.Element | null => total > 10 ? : null; return ( -
- { - initialBlocks.map((block: Block) => { - return +
+ { initialBlocks.map((block: Block) => + - <>{render(block.elements, imageSalt).html} - - }) - } - -
- ) + lastModifiedDate={block.lastModifiedDate} + > + <>{render(block.elements).run(env).html} +
+ ) } + +
+ )) } export default LiveblogBody; diff --git a/src/components/liveblog/LiveblogByline.tsx b/src/components/liveblog/LiveblogByline.tsx index 335a09bb6..4afa5d08f 100644 --- a/src/components/liveblog/LiveblogByline.tsx +++ b/src/components/liveblog/LiveblogByline.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { textSans, PillarStyles, PillarId } from '../../styles'; +import { textSans, PillarStyles, PillarId, getPillarStyles } from 'styles'; import { Keyline } from '../shared/Keyline'; @@ -9,6 +9,8 @@ import { Contributor } from '../../types/Capi'; import { formatDate } from 'utils/date'; import Avatar from 'components/shared/Avatar'; import LeftColumn from 'components/shared/LeftColumn'; +import { Env } from 'types/Env'; +import { Reader } from 'types/Reader'; const LiveblogBylineStyles = ({ liveblogBackground }: PillarStyles): SerializedStyles => css` background: ${liveblogBackground}; @@ -46,31 +48,26 @@ const LiveblogBylineStyles = ({ liveblogBackground }: PillarStyles): SerializedS interface LiveblogBylineProps { byline: string; - pillarStyles: PillarStyles; publicationDate: string; contributors: Contributor[]; pillarId: PillarId; - imageSalt: string; } -const LiveblogByline = ({ +function LiveblogByline({ byline, - pillarStyles, publicationDate, contributors, pillarId, - imageSalt -}: LiveblogBylineProps): JSX.Element => { - - return ( +}: LiveblogBylineProps): Reader { + const pillarStyles = getPillarStyles(pillarId); + + return Avatar({ contributors, bgColour: pillarStyles.featureHeadline }).map(avatar => + // This is not an iterator, ESLint is confused + // eslint-disable-next-line react/jsx-key
- + { avatar }
@@ -78,7 +75,7 @@ const LiveblogByline = ({
- ) + ); } export default LiveblogByline; diff --git a/src/components/news/Article.tsx b/src/components/news/Article.tsx index 4b00f12f3..d76d19c89 100644 --- a/src/components/news/Article.tsx +++ b/src/components/news/Article.tsx @@ -7,31 +7,16 @@ import ArticleStandfirst from './ArticleStandfirst'; import ArticleByline from './ArticleByline'; import ArticleBody from './ArticleBody'; import Tags from '../shared/Tags'; -import { Option } from 'types/Option'; -import { Series, Contributor } from '../../types/Capi'; -import { Tag, Block, BlockElement } from 'types/capi-thrift-models'; -import { PillarId, PillarStyles, darkModeCss, articleWidthStyles } from '../../styles'; +import { Tag } from 'types/capi-thrift-models'; +import { darkModeCss, articleWidthStyles, getPillarStyles } from 'styles'; import { palette, wide } from '@guardian/src-foundations'; import { css } from '@emotion/core'; import { Keyline } from 'components/shared/Keyline'; - -export interface ArticleProps { - headline: string; - standfirst: string; - bylineHtml: string; - webPublicationDate: string; - body: string; - tags: Tag[]; - feature: boolean; - pillarId: PillarId; - mainImage: Option; - starRating?: string; - pillarStyles: PillarStyles; - contributors: Contributor[]; - series: Series; - bodyElements: Block[]; - imageSalt: string; -} +import { Reader } from 'types/Reader'; +import { Env } from 'types/Env'; +import { isFeature } from 'utils/capi'; +import { fromNullable } from 'types/Option'; +import { isImage } from 'components/blocks/image'; const MainStyles = css` background: ${palette.neutral[97]}; @@ -60,56 +45,53 @@ const HeaderImageStyles = css` } `; -const Article = ({ - headline, - standfirst, - bylineHtml, - webPublicationDate, - pillarId, - tags, - feature, - mainImage, - starRating, - bodyElements, - pillarStyles, - contributors, - series, - imageSalt, -}: ArticleProps): JSX.Element => -
-
- -
- - - -
- -
- - - +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const Article = ({ capi }: { capi: any }): Reader => Reader.do(function* () { + + const { type, fields, tags, webPublicationDate, pillarId, blocks } = capi; + const [series] = tags.filter((tag: Tag) => tag.type === 'series'); + const feature = isFeature(tags) || 'starRating' in fields; + const pillarStyles = getPillarStyles(pillarId); + const contributors = tags.filter((tag: Tag) => tag.type === 'contributor'); + const bodyElements = type === 'liveblog' ? blocks.body : blocks.body[0].elements; + const mainImage = fromNullable(blocks.main.elements.filter(isImage)[0]); + + const headerImage = yield HeaderImage({ image: mainImage, className: HeaderImageStyles }); + const articleByline = yield ArticleByline({ + byline: fields.bylineHtml, + pillarStyles, + publicationDate: webPublicationDate, + contributors, + }); + const articleBody = yield ArticleBody({ pillarStyles, bodyElements }); + + return Reader.of( +
+
+ { headerImage } +
+ + + +
+ +
+ { articleByline } + { articleBody } + +
-
-
+ + ); +}()); export default Article; diff --git a/src/components/news/ArticleBody.tsx b/src/components/news/ArticleBody.tsx index 35693a21c..99a42b65f 100644 --- a/src/components/news/ArticleBody.tsx +++ b/src/components/news/ArticleBody.tsx @@ -4,6 +4,8 @@ import { sidePadding, PillarStyles, darkModeCss, commonArticleStyles } from '../ import { palette } from '@guardian/src-foundations' import { render } from "../../renderBlocks"; import { Block } from 'types/capi-thrift-models'; +import { Env } from 'types/Env'; +import { Reader } from 'types/Reader'; const ArticleBodyStyles = (pillarStyles: PillarStyles): SerializedStyles => css` .rich-link, @@ -53,12 +55,16 @@ const ArticleBodyDarkStyles = ({ inverted }: PillarStyles): SerializedStyles => interface ArticleBodyProps { pillarStyles: PillarStyles; bodyElements: Block[]; - imageSalt: string; } -const ArticleBody = ({ bodyElements, pillarStyles, imageSalt }: ArticleBodyProps): JSX.Element => -
- {render(bodyElements, imageSalt).html} -
+const ArticleBody = ({ bodyElements, pillarStyles }: ArticleBodyProps): Reader => + render(bodyElements).map(rendered => + // This is not an iterator, ESLint is confused + // eslint-disable-next-line react/jsx-key +
+ { rendered.html } +
+ ); + export default ArticleBody; diff --git a/src/components/news/ArticleByline.tsx b/src/components/news/ArticleByline.tsx index 82bb9460c..d3d06ec23 100644 --- a/src/components/news/ArticleByline.tsx +++ b/src/components/news/ArticleByline.tsx @@ -7,6 +7,8 @@ import { formatDate } from 'utils/date'; import { Contributor } from 'types/Capi'; import Avatar from 'components/shared/Avatar'; import Follow from 'components/shared/Follow'; +import { Reader } from 'types/Reader'; +import { Env } from 'types/Env'; const ArticleBylineStyles = ({ kicker }: PillarStyles): SerializedStyles => css` .author { @@ -55,7 +57,6 @@ interface ArticleBylineProps { pillarStyles: PillarStyles; publicationDate: string; contributors: Contributor[]; - imageSalt: string; } const ArticleByline = ({ @@ -63,21 +64,21 @@ const ArticleByline = ({ pillarStyles, publicationDate, contributors, - imageSalt -}: ArticleBylineProps): JSX.Element => -
-
- -
-
- - +}: ArticleBylineProps): Reader => + Avatar({ contributors, bgColour: pillarStyles.inverted }).map(avatar => + // This is not an iterator, ESLint is confused + // eslint-disable-next-line react/jsx-key +
+
+ { avatar } +
+
+ + +
-
+ ); + export default ArticleByline; diff --git a/src/components/shared/Avatar.tsx b/src/components/shared/Avatar.tsx index 10f7ffb9e..d32df5599 100644 --- a/src/components/shared/Avatar.tsx +++ b/src/components/shared/Avatar.tsx @@ -5,6 +5,8 @@ import { Contributor } from 'types/Capi'; import { isSingleContributor } from 'utils/capi'; import { css, SerializedStyles } from '@emotion/core'; import { transformUrl } from 'utils/Asset'; +import { Reader } from 'types/Reader'; +import { Env } from 'types/Env'; // ----- Styles ----- // @@ -33,25 +35,23 @@ const AvatarStyles = (bgColour: string): SerializedStyles => css` interface AvatarProps { contributors: Contributor[]; bgColour: string; - imageSalt: string; } -function Avatar({ contributors, bgColour, imageSalt }: AvatarProps): JSX.Element | null { - - const [contributor] = contributors; - - if (isSingleContributor(contributors) && contributor.bylineLargeImageUrl) { - const imgSrc = transformUrl(imageSalt, contributor.bylineLargeImageUrl, imageWidth*3); - return ( -
- {contributor.webTitle}/ -
- ); - } - - return null; - -} +const Avatar = ({ contributors, bgColour }: AvatarProps): Reader => + Reader.asks(({ imageSalt }) => { + const [contributor] = contributors; + + if (isSingleContributor(contributors) && contributor.bylineLargeImageUrl) { + const imgSrc = transformUrl(imageSalt, contributor.bylineLargeImageUrl, imageWidth*3); + return ( +
+ {contributor.webTitle}/ +
+ ); + } + + return null; + }); // ----- Exports ----- // diff --git a/src/components/shared/HeaderImage.tsx b/src/components/shared/HeaderImage.tsx index 06a75e3bc..34ba1795e 100644 --- a/src/components/shared/HeaderImage.tsx +++ b/src/components/shared/HeaderImage.tsx @@ -7,6 +7,8 @@ import { imageElement } from 'components/blocks/image'; import { wide } from '@guardian/src-foundations'; import { wideContentWidth } from 'styles'; import { Option } from 'types/Option'; +import { Reader } from 'types/Reader'; +import { Env } from 'types/Env'; const headerImageStyles = css` figure { @@ -31,26 +33,27 @@ const headerImageStyles = css` interface HeaderImageProps { image: Option; - imageSalt: string; className?: SerializedStyles | null; } -const HeaderImage = ({ className, image, imageSalt }: HeaderImageProps): JSX.Element | null => { - - const headerImage: Option = image.map(({ imageTypeData, assets }) => - // This is not an iterator, ESLint is confused - // eslint-disable-next-line react/jsx-key -
-
- { imageElement(imageTypeData.alt, assets, imageSalt) } - -
-
- ); - - // Needed to provide TypeScript with enough type information. - return headerImage.withDefault(null); - -} +const HeaderImage = ({ className, image }: HeaderImageProps): Reader => + Reader.asks(({ imageSalt }) => { + const headerImage: Option = image.map(({ imageTypeData, assets }) => + // This is not an iterator, ESLint is confused + // eslint-disable-next-line react/jsx-key +
+
+ { imageElement(imageTypeData.alt, assets, imageSalt) } + +
+
+ ); + + // Needed to provide TypeScript with enough type information. + return headerImage.withDefault(null); + }); export default HeaderImage; diff --git a/src/renderBlocks.ts b/src/renderBlocks.ts index b881447f0..b596402bb 100644 --- a/src/renderBlocks.ts +++ b/src/renderBlocks.ts @@ -5,6 +5,8 @@ import jsdom from 'jsdom'; import { Result, Err, fromUnsafe } from './types/Result'; import { imageBlock } from './components/blocks/image'; +import { Env } from 'types/Env'; +import { Reader } from 'types/Reader'; // ----- Setup ----- // @@ -117,34 +119,28 @@ function reactFromElement(element: any, imageSalt: string): Result +const elementsToReact = (elements: any): Reader => + Reader.asks(({ imageSalt }) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elementToReact = ({ errors, nodes }: ParsedReact, element: any): ParsedReact => + reactFromElement(element, imageSalt).either( error => ({ errors: [ ...errors, error ], nodes }), node => ({ errors, nodes: [ ...nodes, node ] }), ); - return elements.reduce(elementToReact, { errors: [], nodes: [] }); - -} + return elements.reduce(elementToReact, { errors: [], nodes: [] }); + }) // eslint-disable-next-line @typescript-eslint/no-explicit-any -function render(bodyElements: any, imageSalt: string): Rendered { - - const reactNodes = elementsToReact(bodyElements, imageSalt); - const main = h('article', null, ...reactNodes.nodes); - - return { - errors: reactNodes.errors, - html: main, - }; - -} +const render = (bodyElements: any): Reader => + elementsToReact(bodyElements).map(({ nodes, errors }) => ({ + errors, + html: h('article', null, ...nodes), + })); // ----- Exports ----- // diff --git a/src/server.ts b/src/server.ts index 4575c9166..246487771 100644 --- a/src/server.ts +++ b/src/server.ts @@ -9,22 +9,16 @@ import React from 'react'; import { renderToString } from 'react-dom/server'; import fetch from 'node-fetch'; -import { fromUnsafe, Result, Ok, Err } from 'types/Result'; -import { Tag, BlockElement } from 'types/capi-thrift-models'; -import Article, { ArticleProps } from 'components/news/Article'; +import { Result, Ok, Err } from 'types/Result'; +import Article from 'components/news/Article'; import LiveblogArticle from 'components/liveblog/LiveblogArticle'; -import { getPillarStyles } from 'styles'; import { getConfigValue } from 'utils/ssmConfig'; -import { isFeature, parseCapi, capiEndpoint } from 'utils/capi'; -import { fromNullable } from 'types/Option'; +import { parseCapi, capiEndpoint } from 'utils/capi'; +import { Reader } from 'types/Reader'; +import { Env } from 'types/Env'; // ----- Setup ----- // -interface CapiFields { - type: string; - articleProps: ArticleProps; -}; - const defaultId = 'cities/2019/sep/13/reclaimed-lakes-and-giant-airports-how-mexico-city-might-have-looked'; const readFileP = promisify(fs.readFile); @@ -32,10 +26,8 @@ const readFileP = promisify(fs.readFile); // ----- Functions ----- // -const id = (a: A): A => a; - // eslint-disable-next-line @typescript-eslint/no-explicit-any -function checkForUnsupportedContent(capi: any): Result { +function checkForUnsupportedContent(capi: any): Result { const { fields, atoms } = capi.response.content; @@ -47,68 +39,29 @@ function checkForUnsupportedContent(capi: any): Result { return new Err('Atoms not yet supported'); } - return new Ok(undefined); + return new Ok(capi); } -const isImage = (elem: BlockElement): boolean => - elem.type === 'image'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const capiFields = (capi: any): Result => - fromUnsafe(() => { - - const { type, fields, tags, webPublicationDate, pillarId, blocks } = capi.response.content; - const bodyElements = type === 'liveblog' ? blocks.body : blocks.body[0].elements; - - const mainImage = fromNullable(blocks.main.elements.filter(isImage)[0]); - const feature = isFeature(tags) || 'starRating' in fields; - const pillarStyles = getPillarStyles(pillarId); - const contributors = tags.filter((tag: Tag) => tag.type === 'contributor'); - const [series] = tags.filter((tag: Tag) => tag.type === 'series'); - - return { - type, - articleProps: { - ...fields, - ...capi.response.content, - webPublicationDate, - feature, - mainImage, - bodyElements, - pillarStyles, - contributors, - series - }, - }; - }, 'Unexpected CAPI response structure'); +function Unimplemented(props: { contentType: string }): JSX.Element { + return React.createElement('p', null, `${props.contentType} not implemented yet`) +} // eslint-disable-next-line @typescript-eslint/no-explicit-any -const fieldsFromCapi = (capi: any): Result => - fromUnsafe(() => checkForUnsupportedContent(capi), 'Unexpected CAPI response structure') - .andThen(id) - .andThen(() => capiFields(capi)); - -const getArticleComponent = (imageSalt: string) => - function ArticleComponent(capiFields: CapiFields): React.ReactElement { - switch (capiFields.type) { +const getArticleComponent = (capi: any): Reader => { + switch (capi.type) { case 'article': - return React.createElement(Article, { ...capiFields.articleProps, imageSalt }); + return Article({ capi }); case 'liveblog': - return React.createElement( - LiveblogArticle, - { ...capiFields.articleProps, isLive: true, imageSalt } - ); + return LiveblogArticle({ capi, isLive: true }); default: - return React.createElement('p', null, `${capiFields.type} not implemented yet`); + return new Reader((): JSX.Element => Unimplemented({ contentType: capi.type })); } - } +} -const generateArticleHtml = (capiResponse: string, imageSalt: string) => - (data: string): Result => - parseCapi(capiResponse) - .andThen(fieldsFromCapi) - .map(getArticleComponent(imageSalt)) +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const generateArticleHtml = (data: string, capi: any): Reader => + getArticleComponent(capi) .map(renderToString) .map(body => data.replace('
', `
${body}
`)) @@ -140,10 +93,15 @@ app.get('/*', async (req, res) => { const key = await getConfigValue("capi.key"); const imageSalt = await getConfigValue('apis.img.salt'); const resp = await fetch(capiEndpoint(articleId, key), {}); - const capi = await resp.text(); + const capiText = await resp.text(); + const parsedCapi = parseCapi(capiText); template - .andThen(generateArticleHtml(capi, imageSalt)) + .andThen((templ: string) => + parsedCapi + .andThen(checkForUnsupportedContent) + .map(capi => generateArticleHtml(templ, capi.response.content).run({ imageSalt })) + ) .either( err => { throw err }, data => res.send(data), diff --git a/src/types/Env.ts b/src/types/Env.ts new file mode 100644 index 000000000..113b13ff4 --- /dev/null +++ b/src/types/Env.ts @@ -0,0 +1,3 @@ +// ----- Exports ----- // + +export type Env = { imageSalt: string }; diff --git a/src/types/Reader.ts b/src/types/Reader.ts new file mode 100644 index 000000000..0c2f78bf5 --- /dev/null +++ b/src/types/Reader.ts @@ -0,0 +1,58 @@ +// ----- Imports ----- // + +import { Monad } from 'types/Monad'; + + +// ----- Class ----- // + +class Reader implements Monad
{ + + run: (e: E) => A + + map(f: (_a: A) => B): Reader { + return new Reader((e: E): B => f(this.run(e))); + } + + andThen(f: (_a: A) => Reader): Reader { + return new Reader((e: E): B => f(this.run(e)).run(e)); + } + + with(f: (_d: D) => E): Reader { + return new Reader((d: D): A => this.run(f(d))); + } + + static asks(f: (e: E) => A): Reader { + return new Reader(f); + }; + + static do(gen: Generator, Reader, A | undefined>): Reader { + + function iterate(value?: A): Reader { + const result = gen.next(value); + + if (result.done) { + return result.value; + } + + return result.value.andThen(iterate); + } + + return iterate(); + + } + + static of(a: A): Reader { + return new Reader((_: E) => a); + } + + constructor(f: (e: E) => A) { + this.run = f; + } +} + + +// ----- Exports ----- // + +export { + Reader, +};