Skip to content

Commit

Permalink
Add automatic og:image generation to docs (#2851)
Browse files Browse the repository at this point in the history
## Summary
This pr includes changes to dynamically build OG Images for every single
subpage in docs.

## How does it work?
The algorithm runs after docusaurus build. It reads titles from markdown
files in docs dictionary, generates images and place .png files in the
build dictionary.
Docusaurus has been configured to automatically link to generated OG
Images.

## How to test this feature?
Command to change current working dictionary:
> cd docs

Command to download libraries:
> yarn install

Command to build project and generete OG Images:
> yarn build

Command to see the result:
> yarn serve
  • Loading branch information
xnameTM authored Apr 26, 2024
1 parent c2956b7 commit b8349c7
Show file tree
Hide file tree
Showing 6 changed files with 588 additions and 8 deletions.
5 changes: 4 additions & 1 deletion docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"build": "docusaurus build && node -r esbuild-register scripts/build-og-images.jsx",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
Expand All @@ -29,6 +29,7 @@
"@emotion/styled": "^11.10.6",
"@mdx-js/react": "^1.6.22",
"@mui/material": "^5.12.0",
"@vercel/og": "^0.6.2",
"babel-polyfill": "^6.26.0",
"babel-preset-expo": "^9.2.2",
"babel-preset-react-native": "^4.0.1",
Expand All @@ -51,6 +52,8 @@
"@docusaurus/module-type-aliases": "^2.4.3",
"@tsconfig/docusaurus": "^1.0.7",
"copy-webpack-plugin": "^11.0.0",
"esbuild": "^0.20.2",
"esbuild-register": "^3.5.0",
"eslint-plugin-mdx": "^2.2.0",
"prettier": "^2.8.4",
"typescript": "^4.7.4",
Expand Down
149 changes: 149 additions & 0 deletions docs/scripts/build-og-images.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { createWriteStream } from 'fs';
import { pipeline } from 'stream';
import { promisify } from 'util';
import path from 'path';
import fs from 'fs';
import OGImageStream from './og-image-stream';

const formatImportantHeaders = (headers) => {
return Object.fromEntries(
headers
.map((header) => header.replace(/---/g, '').split('\n'))
.flat()
.filter((header) => header !== '')
.map((header) => header.split(':').map((part) => part.trim()))
);
};

const formatHeaderToFilename = (header) => {
return `${header
.replace(/[ /]/g, '-')
.replace(/`/g, '')
.replace(/:/g, '')
.toLowerCase()}.png`;
};

const getMarkdownHeader = (path) => {
const content = fs.readFileSync(path, 'utf-8');

// get first text between ---
const importantHeaders = content
.match(/---([\s\S]*?)---/g)
?.filter((header) => header !== '------');

if (importantHeaders) {
const obj = formatImportantHeaders(importantHeaders);

if (obj?.title) {
return obj.title;
}
}

const headers = content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith('#'))
.map((line, index) => ({
level: line.match(/#/g).length,
title: line.replace(/#/g, '').trim(),
index,
}))
.sort((a, b) => a.level - b.level || a.index - b.index);

return headers[0]?.title || 'React Native Reanimated';
};

async function saveStreamToFile(stream, path) {
const writeStream = createWriteStream(path);
await promisify(pipeline)(stream, writeStream);
}

const formatFilesInDoc = async (dir, files, baseDirPath) => {
return await Promise.all(
files.map(async (file) => ({
file,
isDirectory: (
await fs.promises.lstat(path.resolve(baseDirPath, dir, file))
).isDirectory(),
isMarkdown: file.endsWith('.md') || file.endsWith('.mdx'),
}))
);
};

const formatDocInDocs = async (dir, baseDirPath) => {
const files = await fs.promises.readdir(path.resolve(baseDirPath, dir));
return {
dir,
files: (await formatFilesInDoc(dir, files, baseDirPath)).filter(
({ isDirectory, isMarkdown }) => isDirectory || isMarkdown
),
};
};

const extractSubFiles = async (dir, files, baseDirPath) => {
return (
await Promise.all(
files.map(async (file) => {
if (!file.isDirectory) return file.file;

const subFiles = (
await fs.promises.readdir(path.resolve(baseDirPath, dir, file.file))
).filter((file) => file.endsWith('.md') || file.endsWith('.mdx'));

return subFiles.map((subFile) => `${file.file}/${subFile}`);
})
)
).flat();
};

const getDocs = async (baseDirPath) => {
let docs = await Promise.all(
(
await fs.promises.readdir(baseDirPath)
).map(async (dir) => formatDocInDocs(dir, baseDirPath))
);

docs = await Promise.all(
docs.map(async ({ dir, files }) => ({
dir,
files: await extractSubFiles(dir, files, baseDirPath),
}))
);

return docs;
};

async function buildOGImages() {
const baseDirPath = path.resolve(__dirname, '../docs');

const docs = await getDocs(baseDirPath);

const ogImageTargets = path.resolve(__dirname, '../build/img/og');

if (fs.existsSync(ogImageTargets)) {
fs.rmSync(ogImageTargets, { recursive: true });
}

fs.mkdirSync(ogImageTargets, { recursive: true });

console.log('Generating OG images for docs...');

const imagePath = path.resolve(__dirname, '../unproccessed/og-image.png');
const imageBuffer = fs.readFileSync(imagePath);
const base64Image = `data:image/png;base64,${imageBuffer.toString('base64')}`;

docs.map(async ({ dir, files }) => {
files.map(async (file) => {
const header = getMarkdownHeader(path.resolve(baseDirPath, dir, file));

const ogImageStream = await OGImageStream(header, base64Image);

await saveStreamToFile(
ogImageStream,
path.resolve(ogImageTargets, formatHeaderToFilename(header))
);
});
});
}

buildOGImages();
90 changes: 90 additions & 0 deletions docs/scripts/og-image-stream.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import React from 'react';
import path from 'path';
import { ImageResponse } from '@vercel/og';
import fs from 'fs';

export default async function OGImageStream(header, base64Image) {
return (
new ImageResponse(
(
<div
style={{
display: 'flex',
fontSize: 40,
color: 'black',
background: 'white',
width: '100%',
height: '100%',
padding: '50px 600px',
textAlign: 'center',
justifyContent: 'center',
alignItems: 'center',
}}>
<img
style={{
width: 1200,
height: 630,
objectFit: 'cover',
position: 'absolute',
top: 0,
left: 0,
}}
src={base64Image}
alt=""
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
width: 1200,
gap: -20,
padding: '0 201px 0 67px',
}}>
<p
style={{
fontSize: 72,
fontWeight: 'bold',
color: '#001A72',
textAlign: 'left',
fontFamily: 'Aeonik Bold',
textWrap: 'wrap',
}}>
{header}
</p>
<pre
style={{
fontSize: 40,
fontWeight: 'normal',
color: '#001A72',
textAlign: 'left',
fontFamily: 'Aeonik Regular',
textWrap: 'wrap',
}}>
{'Check out the React Native\nReanimated documentation.'}
</pre>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Aeonik Bold',
data: fs.readFileSync(
path.resolve(__dirname, '../static/fonts/Aeonik-Bold.otf')
),
style: 'normal',
},
{
name: 'Aeonik Regular',
data: fs.readFileSync(
path.resolve(__dirname, '../static/fonts/Aeonik-Regular.otf')
),
style: 'normal',
},
],
}
).body
);
}
23 changes: 23 additions & 0 deletions docs/src/theme/DocItem/Metadata/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { PageMetadata } from '@docusaurus/theme-common';
import { useDoc } from '@docusaurus/theme-common/internal';
export default function DocItemMetadata() {
const { metadata, frontMatter } = useDoc();

if (!metadata) return null;

const ogImageName = metadata.title
.replace(/[ /]/g, '-')
.replace(/`/g, '')
.replace(/:/g, '')
.toLowerCase();

return (
<PageMetadata
title={metadata.title}
description={metadata.description}
keywords={frontMatter.keywords}
image={`img/og/${ogImageName}.png`}
/>
);
}
Binary file added docs/unproccessed/og-image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit b8349c7

Please sign in to comment.