Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add automatic og:image generation to docs #2851

Merged
merged 9 commits into from
Apr 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading