Skip to content
This repository has been archived by the owner on Sep 1, 2021. It is now read-only.

Image Resizer (Fastly) For Body Images #42

Merged
merged 9 commits into from
Oct 4, 2019
Merged
26 changes: 6 additions & 20 deletions src/components/blocks/image.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// ----- Imports ----- //

import React from 'react';
import * as Asset from 'utils/Asset';


// ----- Setup ----- //
Expand All @@ -14,38 +15,23 @@ interface Image {
alt: string;
}

interface Asset {
type: string;
file: string;
typeData: {
width: number;
isMaster?: boolean;
};
}


// ----- Functions ----- //

const srcSet = (assets: Asset[]): string =>
assets
.filter(a => !a.typeData.isMaster)
.map(a => `${a.file} ${a.typeData.width}w`)
.join(', ');

const imageElement = (image: Image, assets: Asset[]): React.ReactNode =>
const imageElement = (image: Image, assets: Asset.Asset[], salt: string): React.ReactNode =>
h('img', {
sizes: '100%',
srcSet: srcSet(assets),
srcSet: Asset.toSrcset(salt, assets).withDefault(''),
alt: image.alt,
src: assets[0].file,
src: Asset.toUrl(salt, assets[0]),
});

function imageBlock(image: Image, assets: Asset[]): React.ReactNode {
function imageBlock(image: Image, assets: Asset.Asset[], salt: string): React.ReactNode {

const caption = image.displayCredit ? `${image.caption} ${image.credit}` : image.caption;

return h('figure', { className: 'image' },
imageElement(image, assets),
imageElement(image, assets, salt),
h('figcaption', null, caption),
);
}
Expand Down
3 changes: 2 additions & 1 deletion src/components/liveblog/LiveblogArticle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import HeaderImage from '../shared/HeaderImage';
import Tags from '../shared/Tags';

import { PillarStyles, PillarId } from '../../styles';
import { Series, Tag, Asset, Contributor } from '../../types/Capi';
import { Series, Tag, Contributor } from '../../types/Capi';
import { Asset } from 'utils/Asset';
import { css, SerializedStyles } from '@emotion/core'
import { palette } from '@guardian/src-foundations'

Expand Down
11 changes: 9 additions & 2 deletions src/components/news/Article.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import ArticleByline from './ArticleByline';
import ArticleBody from './ArticleBody';
import Tags from '../shared/Tags';

import { Series, Tag, Asset, Contributor } from '../../types/Capi';
import { Series, Tag, Contributor } from '../../types/Capi';
import { Asset } from 'utils/Asset';
import { PillarId, PillarStyles, darkModeCss } from '../../styles';
import { palette } from '@guardian/src-foundations';

Expand All @@ -28,6 +29,7 @@ export interface ArticleProps {
series: Series;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bodyElements: any;
imageSalt: string;
}

const MainStyles = darkModeCss`
Expand All @@ -48,6 +50,7 @@ const Article = ({
pillarStyles,
contributors,
series,
imageSalt,
}: ArticleProps): JSX.Element =>
<main css={MainStyles}>
<HeaderImage assets={mainAssets}/>
Expand All @@ -66,7 +69,11 @@ const Article = ({
publicationDate={webPublicationDate}
contributors={contributors}
/>
<ArticleBody pillarStyles={pillarStyles} bodyElements={bodyElements}/>
<ArticleBody
pillarStyles={pillarStyles}
bodyElements={bodyElements}
imageSalt={imageSalt}
/>
<Tags tags={tags}/>
</main>

Expand Down
5 changes: 3 additions & 2 deletions src/components/news/ArticleBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,12 @@ interface ArticleBodyProps {
pillarStyles: PillarStyles;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
bodyElements: any;
imageSalt: string;
}

const ArticleBody = ({ bodyElements, pillarStyles }: ArticleBodyProps): JSX.Element =>
const ArticleBody = ({ bodyElements, pillarStyles, imageSalt }: ArticleBodyProps): JSX.Element =>
<article css={[articleBodyStyles(pillarStyles), ArticleBodyDarkStyles(pillarStyles)]}>
{render(bodyElements).html}
{render(bodyElements, imageSalt).html}
</article>

export default ArticleBody;
12 changes: 6 additions & 6 deletions src/renderBlocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ const richLinkBlock = (url: string, linkText: string): ReactNode =>
);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function reactFromElement(element: any): Result<string, ReactNode> {
function reactFromElement(element: any, imageSalt: string): Result<string, ReactNode> {

switch (element.type) {
case 'text':
Expand Down Expand Up @@ -111,7 +111,7 @@ function reactFromElement(element: any): Result<string, ReactNode> {

return fromUnsafe(() => {
const { imageTypeData, assets } = element;
return imageBlock(imageTypeData, assets)
return imageBlock(imageTypeData, assets, imageSalt)
}, 'Failed to parse image');

default:
Expand All @@ -121,10 +121,10 @@ function reactFromElement(element: any): Result<string, ReactNode> {
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function elementsToReact(elements: any): ParsedReact {
function elementsToReact(elements: any, imageSalt: string): ParsedReact {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const elementToReact = ({ errors, nodes }: ParsedReact, element: any): ParsedReact =>
reactFromElement(element).either(
reactFromElement(element, imageSalt).either(
error => ({ errors: [ ...errors, error ], nodes }),
node => ({ errors, nodes: [ ...nodes, node ] }),
);
Expand All @@ -134,9 +134,9 @@ function elementsToReact(elements: any): ParsedReact {
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
function render(bodyElements: any): Rendered {
function render(bodyElements: any, imageSalt: string): Rendered {

const reactNodes = elementsToReact(bodyElements);
const reactNodes = elementsToReact(bodyElements, imageSalt);
const main = h('article', null, ...reactNodes.nodes);

return {
Expand Down
91 changes: 56 additions & 35 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ app.use(compression());
const capiEndpoint = (articleId: string, key: string): string =>
`https://content.guardianapis.com/${articleId}?format=json&api-key=${key}&show-elements=all&show-atoms=all&show-fields=all&show-tags=all&show-blocks=all`;

const defaultId = 'cities/2019/sep/13/reclaimed-lakes-and-giant-airports-how-mexico-city-might-have-looked';

interface CapiFields {
type: string;
articleProps: ArticleProps;
Expand Down Expand Up @@ -83,52 +85,71 @@ const capiFields = (capi: any): Result<string, CapiFields> =>


// eslint-disable-next-line @typescript-eslint/no-explicit-any
function fieldsFromCapi(capi: any): Result<string, CapiFields> {

return fromUnsafe(() => checkForUnsupportedContent(capi), 'Unexpected CAPI response structure')
const fieldsFromCapi = (capi: any): Result<string, CapiFields> =>
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) {
case 'article':
return React.createElement(Article, { ...capiFields.articleProps, imageSalt });
case 'liveblog':
return React.createElement(LiveblogArticle, {...capiFields.articleProps, isLive: true});
default:
return React.createElement('p', null, `${capiFields.type} not implemented yet`);
}
}

const getArticleComponent = (capiFields: CapiFields): React.ReactElement => {
switch (capiFields.type) {
case 'article':
return React.createElement(Article, capiFields.articleProps);
case 'liveblog':
return React.createElement(LiveblogArticle, {...capiFields.articleProps, isLive: true});
default:
return React.createElement('p', null, `${capiFields.type} not implemented yet`);
const generateArticleHtml = (capiResponse: string, imageSalt: string) =>
(data: string): Result<string, string> =>
parseCapi(capiResponse)
.andThen(fieldsFromCapi)
.map(getArticleComponent(imageSalt))
.map(renderToString)
.map(body => data.replace('<div id="root"></div>', `<div id="root">${body}</div>`))

const readFileP = (file: string, encoding: string): Promise<string> =>
new Promise((res, rej): void => {
fs.readFile(file, encoding, (err, data) => err ? rej(err) : res(data));
});

async function readTemplate(): Promise<Result<string, string>> {
try {
const data = await readFileP(path.resolve('./src/html/articleTemplate.html'), 'utf8');
return new Ok(data);
} catch (_) {
return new Err('Could not read template file');
}
}

const generateArticleHtml = (capiResponse: string, data: string): string =>
parseCapi(capiResponse)
.andThen(fieldsFromCapi)
.map(getArticleComponent)
.map(renderToString)
.map(body => data.replace('<div id="root"></div>', `<div id="root">${body}</div>`))
.either(id, id);
app.get('/*', async (req, res) => {

app.get('/*', (req, res) => {
try {
fs.readFile(path.resolve('./src/html/articleTemplate.html'), 'utf8', (err, data) => {
if (err) {
console.error(err)
return res.status(500).send('An error occurred')
}

const articleId = req.params[0] || 'cities/2019/sep/13/reclaimed-lakes-and-giant-airports-how-mexico-city-might-have-looked';

getConfigValue<string>("capi.key")
.then(key => fetch(capiEndpoint(articleId, key), {}))
.then(resp => resp.text())
.then(capi => res.send(generateArticleHtml(capi, data)))
.catch(error => res.send(`<pre>${error}</pre>`))
})

const articleId = req.params[0] || defaultId;

const template = await readTemplate();
const key = await getConfigValue<string>("capi.key");
const imageSalt = await getConfigValue<string>('apis.img.salt');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it would make much difference, but could we do this in parallel?

let [template, key, imageSalt] = await Promise.allSettled([readTemplate(), getConfigValue<string>("capi.key"), getConfigValue<string>('apis.img.salt')]);

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That requires Node 12.9... Promise.all?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed capi key won't make it to production, so performance not too much of an issue.

const resp = await fetch(capiEndpoint(articleId, key), {});
const capi = await resp.text();

template
.andThen(generateArticleHtml(capi, imageSalt))
.either(
err => { throw err },
data => res.send(data),
);

} catch (e) {
res.status(500).send(`<pre>${e.stack}</pre>`);

console.error(e);
res.status(500).send('An error occurred, check the console');

}

});

app.listen(3040);
15 changes: 1 addition & 14 deletions src/types/Capi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { PillarId } from "../styles";
import { Asset } from "../components/shared/HeaderImage";
import { Asset } from 'utils/Asset';

export interface Capi {
response: {
Expand Down Expand Up @@ -49,19 +49,6 @@ export interface Series {
webUrl?: string;
}

export interface Asset {
file: string;
typeData: AssetTypeData;
}

export interface AssetTypeData {
altText: string;
caption: string;
credit: string;
width: number;
height: number;
}

export interface Tag {
webUrl: string;
webTitle: string;
Expand Down
7 changes: 7 additions & 0 deletions src/types/Option.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,17 @@ class None<A> implements OptionInterface<A> {
type Option<A> = Some<A> | None<A>;


// ----- Constructors ----- //

const fromNullable = <A>(a: A | null | undefined): Option<A> =>
a === null || a === undefined ? new None() : new Some(a);


// ----- Exports ----- //

export {
Option,
Some,
None,
fromNullable,
};
Loading