Skip to content

Commit

Permalink
feat(SEO): dynamic og image generator
Browse files Browse the repository at this point in the history
  • Loading branch information
andreasasprou committed Dec 27, 2021
1 parent 45ca3d9 commit 49d371b
Show file tree
Hide file tree
Showing 17 changed files with 496 additions and 19 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ You can check out [the Next.js GitHub repository](https://github.com/vercel/next
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

## Features

- Dynamic open graph image generator for each blog post
8 changes: 8 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ module.exports = {
reactStrictMode: true,
optimizeFonts: false,
productionBrowserSourceMaps: true,
async rewrites() {
return [
{
source: '/api/og-image/(.+)',
destination: '/api/og-image',
},
];
},
redirects: function () {
return [
{
Expand Down
7 changes: 7 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@
"dependencies": {
"@heroicons/react": "^1.0.5",
"@notionhq/client": "^0.4.11",
"chrome-aws-lambda": "^10.1.0",
"classnames": "^2.3.1",
"dayjs": "^1.10.7",
"lodash.throttle": "^4.1.1",
"marked": "^4.0.8",
"next": "12.0.7",
"next-seo": "^4.28.1",
"nprogress": "^0.2.0",
"prismjs": "^1.25.0",
"puppeteer-core": "7.0.0",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-tweet-embed": "^1.3.1",
"sitemap": "^7.0.0",
"twemoji": "^13.1.0",
"uuid": "^8.3.2"
},
"devDependencies": {
Expand All @@ -38,10 +42,13 @@
"@testing-library/jest-dom": "^5.16.1",
"@testing-library/react": "^12.1.2",
"@types/lodash.throttle": "^4.1.6",
"@types/marked": "^4.0.1",
"@types/node": "17.0.4",
"@types/nprogress": "^0.2.0",
"@types/prismjs": "^1.16.6",
"@types/puppeteer-core": "^5.4.0",
"@types/react": "17.0.38",
"@types/twemoji": "^12.1.2",
"@types/uuid": "^8.3.3",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
Expand Down
3 changes: 3 additions & 0 deletions pages/api/og-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ogGeneratorHandler } from 'shared/server/og-generator';

export default ogGeneratorHandler;
8 changes: 5 additions & 3 deletions pages/blog/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,19 @@ import {
} from 'next';
import dayjs from 'dayjs';
import { WebLayout } from 'components/layouts/WebLayout';
import { ROUTES } from 'shared/constants/client';
import { getPostBySlug } from 'shared/server/notion';
import { APIRoutes, ROUTES } from 'shared/constants/client';
import { BlockRenderer } from 'components/notion/BlockRenderer';
import { TableOfContents } from 'components/notion/TableOfContents';
import { getPostBySlug } from 'shared/server/blog/notion';

function SlugPage({ post }: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<WebLayout
title={post.pageInfo.name}
description={post.pageInfo.excerpt}
ogImage="https://andreas.fyi/key-chars-of-early-stage-founders-og-image.jpg"
ogImage={`${APIRoutes.OG_IMAGE}/${encodeURIComponent(
post.pageInfo.name,
)}.jpeg`}
url={`https://andreas.fyi${ROUTES.Blog.post(post.pageInfo.slug)}`}
>
<div className="border-b border-b-white/20 pb-4 mb-4 md:pb-8 md:mb-8">
Expand Down
17 changes: 12 additions & 5 deletions pages/blog/index.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import React from 'react';
import { WebLayout } from 'components/layouts/WebLayout';
import { CustomPage } from 'shared/types';
import { VStack } from 'components/VStack';
import { GetStaticPaths, InferGetStaticPropsType } from 'next';
import { InferGetStaticPropsType } from 'next';
import Link from 'next/link';
import dayjs from 'dayjs';
import { getPosts } from 'shared/server/notion';
import { ROUTES } from 'shared/constants/client';
import { getPosts } from 'shared/server/blog/notion';
import { APIRoutes, ROUTES } from 'shared/constants/client';
import { ArrowRightIcon } from '@heroicons/react/solid';

interface PostLinkProps {
Expand Down Expand Up @@ -51,7 +50,15 @@ const Blog: CustomPage = ({
);
};

Blog.getLayout = (page) => <WebLayout title="Blog">{page}</WebLayout>;
Blog.getLayout = (page) => (
<WebLayout
title="Blog"
ogImage={`${APIRoutes.OG_IMAGE}/${encodeURIComponent('Blog')}`}
url={`https://andreas.fyi${ROUTES.Blog.Home}`}
>
{page}
</WebLayout>
);

export async function getStaticProps() {
return {
Expand Down
4 changes: 2 additions & 2 deletions pages/sitemap.xml.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Readable } from 'stream';

import { SitemapStream, streamToPromise, SitemapItemLoose } from 'sitemap';
import { GetServerSideProps } from 'next';
import { getAllPosts } from '../shared/server/notion';
import { ROUTES } from '../shared/constants/client';
import { getAllPosts } from 'shared/server/blog/notion';
import { ROUTES } from 'shared/constants/client';

const Sitemap = () => null;

Expand Down
4 changes: 4 additions & 0 deletions shared/constants/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export const ROUTES = {
},
};

export const APIRoutes = {
OG_IMAGE: '/api/og-image',
};

export const LayoutConstants = {
textMaxWidth: 800,
margin: {
Expand Down
22 changes: 22 additions & 0 deletions shared/server/og-generator/chromium.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import core from 'puppeteer-core';
import { getOptions } from './options';
import { FileType } from './types';
let _page: core.Page | null;

async function getPage(isDev: boolean) {
if (_page) {
return _page;
}
const options = await getOptions(isDev);
const browser = await core.launch(options);
_page = await browser.newPage();
return _page;
}

export async function getScreenshot(html: string, type: FileType, isDev: boolean) {
const page = await getPage(isDev);
await page.setViewport({ width: 2048, height: 1170 });
await page.setContent(html);
const file = await page.screenshot({ type });
return file;
}
1 change: 1 addition & 0 deletions shared/server/og-generator/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './og-generator.handler';
36 changes: 36 additions & 0 deletions shared/server/og-generator/og-generator.handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { IncomingMessage, ServerResponse } from 'http';
import { parseRequest } from './parser';
import { getScreenshot } from './chromium';
import { getHtml } from './template';

const isDev = !process.env.AWS_REGION;
const isHtmlDebug = process.env.OG_HTML_DEBUG === '1';

export async function ogGeneratorHandler(
req: IncomingMessage,
res: ServerResponse,
) {
try {
const parsedReq = parseRequest(req);
const html = getHtml(parsedReq);
if (isHtmlDebug) {
res.setHeader('Content-Type', 'text/html');
res.end(html);
return;
}
const { fileType } = parsedReq;
const file = await getScreenshot(html, fileType, isDev);
res.statusCode = 200;
res.setHeader('Content-Type', `image/${fileType}`);
res.setHeader(
'Cache-Control',
`public, immutable, no-transform, s-maxage=31536000, max-age=31536000`,
);
res.end(file);
} catch (e) {
res.statusCode = 500;
res.setHeader('Content-Type', 'text/html');
res.end('<div></div>');
console.error(e);
}
}
30 changes: 30 additions & 0 deletions shared/server/og-generator/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import chrome from 'chrome-aws-lambda';
const exePath = process.platform === 'win32'
? 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
: process.platform === 'linux'
? '/usr/bin/google-chrome'
: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';

interface Options {
args: string[];
executablePath: string;
headless: boolean;
}

export async function getOptions(isDev: boolean) {
let options: Options;
if (isDev) {
options = {
args: [],
executablePath: exePath,
headless: true
};
} else {
options = {
args: chrome.args,
executablePath: await chrome.executablePath,
headless: chrome.headless,
};
}
return options;
}
32 changes: 32 additions & 0 deletions shared/server/og-generator/parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { IncomingMessage } from 'http';
import { parse } from 'url';
import { APIRoutes } from '../../constants/client';
import { ParsedRequest } from './types';

const BASE_URL = `${APIRoutes.OG_IMAGE}/`;

export function parseRequest(req: IncomingMessage) {
console.log('HTTP ' + req.url);
const { pathname, query } = parse(req.url || '/', true);
const { md } = query || {};

const arr = (pathname ?? '').replace(BASE_URL, '').split('.');
let extension = '';
let text = '';
if (arr.length === 0) {
text = '';
} else if (arr.length === 1) {
text = arr[0];
} else {
extension = arr.pop() as string;
text = arr.join('.');
}

const parsedRequest: ParsedRequest = {
fileType: extension === 'jpeg' ? extension : 'png',
text: decodeURIComponent(text),
md: md === '1' || md === 'true',
};

return parsedRequest;
}
12 changes: 12 additions & 0 deletions shared/server/og-generator/sanitizer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
const entityMap: { [key: string]: string } = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
};

export function sanitizeHtml(html: string) {
return String(html).replace(/[&<>"'/]/g, (key) => entityMap[key]);
}
87 changes: 87 additions & 0 deletions shared/server/og-generator/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { readFileSync } from 'fs';
import { marked } from 'marked';
import twemoji from 'twemoji';
import { sanitizeHtml } from './sanitizer';
import { ParsedRequest } from './types';
const twOptions = { folder: 'svg', ext: '.svg' };
const emojify = (text: string) => twemoji.parse(text, twOptions);

let rglr: string;

const readFonts = () => {
if (rglr) {
return;
}

rglr = readFileSync(
`${__dirname}/../../../../public/fonts/silka/silka-medium-webfont.woff2`,
).toString('base64');
};

function getCss() {
readFonts();

const background = '#000000';
const headingColor = '#F2AA4C';
const bodyColor = '#ffffff';

return `
@font-face {
font-family: "Silka";
src: url(data:font/woff2;charset=utf-8;base64,${rglr}) format('woff2');
font-weight: 400;
font-style: normal;
}
body {
background: ${background};
color: ${bodyColor};
height: 100vh;
display: flex;
text-align: center;
align-items: center;
justify-content: center;
font-size: 80px;
font-family: Silka;
}
.emoji {
height: 1em;
width: 1em;
margin: 0 .05em 0 .1em;
vertical-align: -0.1em;
}
.heading {
color: ${headingColor};
line-height: 1.3;
margin-bottom: 20px;
max-width: 70vw;
}
p {
margin: 0;
}`;
}

export function getHtml(parsedReq: ParsedRequest) {
const { text, md } = parsedReq;
return `<!DOCTYPE html>
<html>
<meta charset="utf-8">
<title>Generated Image</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
${getCss()}
</style>
<body>
<div>
<div class="heading">${emojify(
md ? marked(text) : sanitizeHtml(text),
)}
</div>
<p>andreas.fyi</p>
</div>
</body>
</html>`;
}
7 changes: 7 additions & 0 deletions shared/server/og-generator/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export type FileType = 'png' | 'jpeg';

export interface ParsedRequest {
fileType: FileType;
text: string;
md?: boolean;
}
Loading

0 comments on commit 49d371b

Please sign in to comment.